diff --git a/runtime/cel/doc.go b/runtime/cel/doc.go
new file mode 100644
index 00000000..63fbca56
--- /dev/null
+++ b/runtime/cel/doc.go
@@ -0,0 +1,18 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// cel provides utilities for evaluating Common Expression Language (CEL) expressions.
+package cel
diff --git a/runtime/cel/expression.go b/runtime/cel/expression.go
new file mode 100644
index 00000000..cd72e3cc
--- /dev/null
+++ b/runtime/cel/expression.go
@@ -0,0 +1,138 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/cel-go/cel"
+ "github.com/google/cel-go/common/types"
+ "github.com/google/cel-go/ext"
+)
+
+// Expression represents a parsed CEL expression.
+type Expression struct {
+ expr string
+ prog cel.Program
+}
+
+// Option is a function that configures the CEL expression.
+type Option func(*options)
+
+type options struct {
+ variables []cel.EnvOption
+ compile bool
+ outputType *cel.Type
+}
+
+// WithStructVariables declares variables of type google.protobuf.Struct.
+func WithStructVariables(vars ...string) Option {
+ return func(o *options) {
+ for _, v := range vars {
+ d := cel.Variable(v, cel.ObjectType("google.protobuf.Struct"))
+ o.variables = append(o.variables, d)
+ }
+ }
+}
+
+// WithCompile specifies that the expression should be compiled,
+// which provides stricter checks at parse time, before evaluation.
+func WithCompile() Option {
+ return func(o *options) {
+ o.compile = true
+ }
+}
+
+// WithOutputType specifies the expected output type of the expression.
+func WithOutputType(t *cel.Type) Option {
+ return func(o *options) {
+ o.outputType = t
+ }
+}
+
+// NewExpression parses the given CEL expression and returns a new Expression.
+func NewExpression(expr string, opts ...Option) (*Expression, error) {
+ var o options
+ for _, opt := range opts {
+ opt(&o)
+ }
+
+ if !o.compile && (o.outputType != nil || len(o.variables) > 0) {
+ return nil, fmt.Errorf("output type and variables can only be set when compiling the expression")
+ }
+
+ envOpts := append([]cel.EnvOption{
+ cel.HomogeneousAggregateLiterals(),
+ cel.EagerlyValidateDeclarations(true),
+ cel.DefaultUTCTimeZone(true),
+ cel.CrossTypeNumericComparisons(true),
+ cel.OptionalTypes(),
+ ext.Strings(),
+ ext.Sets(),
+ ext.Encoders(),
+ }, o.variables...)
+
+ env, err := cel.NewEnv(envOpts...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create CEL environment: %w", err)
+ }
+
+ parse := env.Parse
+ if o.compile {
+ parse = env.Compile
+ }
+ e, issues := parse(expr)
+ if issues != nil {
+ return nil, fmt.Errorf("failed to parse the CEL expression '%s': %s", expr, issues.String())
+ }
+
+ if w, g := o.outputType, e.OutputType(); w != nil && w != g {
+ return nil, fmt.Errorf("CEL expression output type mismatch: expected %s, got %s", w, g)
+ }
+
+ progOpts := []cel.ProgramOption{
+ cel.EvalOptions(cel.OptOptimize),
+
+ // 100 is the kubernetes default:
+ // https://github.com/kubernetes/kubernetes/blob/3f26d005571dc5903e7cebae33ada67986bc40f3/staging/src/k8s.io/apiserver/pkg/apis/cel/config.go#L33-L35
+ cel.InterruptCheckFrequency(100),
+ }
+
+ prog, err := env.Program(e, progOpts...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create CEL program: %w", err)
+ }
+
+ return &Expression{
+ expr: expr,
+ prog: prog,
+ }, nil
+}
+
+// EvaluateBoolean evaluates the expression with the given data and returns the result as a boolean.
+func (e *Expression) EvaluateBoolean(ctx context.Context, data map[string]any) (bool, error) {
+ val, _, err := e.prog.ContextEval(ctx, data)
+ if err != nil {
+ return false, fmt.Errorf("failed to evaluate the CEL expression '%s': %w", e.expr, err)
+ }
+ result, ok := val.(types.Bool)
+ if !ok {
+ return false, fmt.Errorf("failed to evaluate CEL expression as boolean: '%s'", e.expr)
+ }
+ return bool(result), nil
+}
diff --git a/runtime/cel/expression_test.go b/runtime/cel/expression_test.go
new file mode 100644
index 00000000..bdf8582f
--- /dev/null
+++ b/runtime/cel/expression_test.go
@@ -0,0 +1,221 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel_test
+
+import (
+ "context"
+ "testing"
+
+ celgo "github.com/google/cel-go/cel"
+ . "github.com/onsi/gomega"
+
+ "github.com/fluxcd/pkg/runtime/cel"
+)
+
+func TestNewExpression(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ expr string
+ opts []cel.Option
+ err string
+ }{
+ {
+ name: "valid expression",
+ expr: "foo",
+ },
+ {
+ name: "invalid expression",
+ expr: "foo.",
+ err: "failed to parse the CEL expression 'foo.': ERROR: :1:5: Syntax error: no viable alternative at input '.'",
+ },
+ {
+ name: "compilation detects undeclared references",
+ expr: "foo",
+ opts: []cel.Option{cel.WithCompile()},
+ err: "failed to parse the CEL expression 'foo': ERROR: :1:1: undeclared reference to 'foo'",
+ },
+ {
+ name: "compilation detects type errors",
+ expr: "foo == 'bar'",
+ opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")},
+ err: "failed to parse the CEL expression 'foo == 'bar'': ERROR: :1:5: found no matching overload for '_==_' applied to '(map(string, dyn), string)'",
+ },
+ {
+ name: "can't check output type without compiling",
+ expr: "foo",
+ opts: []cel.Option{cel.WithOutputType(celgo.BoolType)},
+ err: "output type and variables can only be set when compiling the expression",
+ },
+ {
+ name: "can't declare variables without compiling",
+ expr: "foo",
+ opts: []cel.Option{cel.WithStructVariables("foo")},
+ err: "output type and variables can only be set when compiling the expression",
+ },
+ {
+ name: "compilation checks output type",
+ expr: "'foo'",
+ opts: []cel.Option{cel.WithCompile(), cel.WithOutputType(celgo.BoolType)},
+ err: "CEL expression output type mismatch: expected bool, got string",
+ },
+ {
+ name: "compilation checking output type can't predict type of struct field",
+ expr: "foo.bar.baz",
+ opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)},
+ err: "CEL expression output type mismatch: expected bool, got dyn",
+ },
+ {
+ name: "compilation checking output type can't predict type of struct field, but if it's a boolean it can be compared to a boolean literal",
+ expr: "foo.bar.baz == true",
+ opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo"), cel.WithOutputType(celgo.BoolType)},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ g := NewWithT(t)
+
+ e, err := cel.NewExpression(tt.expr, tt.opts...)
+
+ if tt.err != "" {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring(tt.err))
+ g.Expect(e).To(BeNil())
+ } else {
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(e).NotTo(BeNil())
+ }
+ })
+ }
+}
+
+func TestExpression_EvaluateBoolean(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ expr string
+ opts []cel.Option
+ data map[string]any
+ result bool
+ err string
+ }{
+ {
+ name: "inexistent field",
+ expr: "foo",
+ data: map[string]any{},
+ err: "failed to evaluate the CEL expression 'foo': no such attribute(s): foo",
+ },
+ {
+ name: "boolean field true",
+ expr: "foo",
+ data: map[string]any{"foo": true},
+ result: true,
+ },
+ {
+ name: "boolean field false",
+ expr: "foo",
+ data: map[string]any{"foo": false},
+ result: false,
+ },
+ {
+ name: "nested boolean field true",
+ expr: "foo.bar",
+ data: map[string]any{"foo": map[string]any{"bar": true}},
+ result: true,
+ },
+ {
+ name: "nested boolean field false",
+ expr: "foo.bar",
+ data: map[string]any{"foo": map[string]any{"bar": false}},
+ result: false,
+ },
+ {
+ name: "boolean literal true",
+ expr: "true",
+ data: map[string]any{},
+ result: true,
+ },
+ {
+ name: "boolean literal false",
+ expr: "false",
+ data: map[string]any{},
+ result: false,
+ },
+ {
+ name: "non-boolean literal",
+ expr: "'some-value'",
+ data: map[string]any{},
+ err: "failed to evaluate CEL expression as boolean: ''some-value''",
+ },
+ {
+ name: "non-boolean field",
+ expr: "foo",
+ data: map[string]any{"foo": "some-value"},
+ err: "failed to evaluate CEL expression as boolean: 'foo'",
+ },
+ {
+ name: "nested non-boolean field",
+ expr: "foo.bar",
+ data: map[string]any{"foo": map[string]any{"bar": "some-value"}},
+ err: "failed to evaluate CEL expression as boolean: 'foo.bar'",
+ },
+ {
+ name: "complex expression evaluating true",
+ expr: "foo && bar",
+ data: map[string]any{"foo": true, "bar": true},
+ result: true,
+ },
+ {
+ name: "complex expression evaluating false",
+ expr: "foo && bar",
+ data: map[string]any{"foo": true, "bar": false},
+ result: false,
+ },
+ {
+ name: "compiled expression returning true",
+ expr: "foo.bar",
+ opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")},
+ data: map[string]any{"foo": map[string]any{"bar": true}},
+ result: true,
+ },
+ {
+ name: "compiled expression returning false",
+ expr: "foo.bar",
+ opts: []cel.Option{cel.WithCompile(), cel.WithStructVariables("foo")},
+ data: map[string]any{"foo": map[string]any{"bar": false}},
+ result: false,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ g := NewWithT(t)
+
+ e, err := cel.NewExpression(tt.expr, tt.opts...)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ result, err := e.EvaluateBoolean(context.Background(), tt.data)
+
+ if tt.err != "" {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring(tt.err))
+ } else {
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(result).To(Equal(tt.result))
+ }
+ })
+ }
+}
diff --git a/runtime/cel/status_evaluator.go b/runtime/cel/status_evaluator.go
new file mode 100644
index 00000000..3638482b
--- /dev/null
+++ b/runtime/cel/status_evaluator.go
@@ -0,0 +1,130 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/fluxcd/pkg/apis/kustomize"
+)
+
+// StatusEvaluator evaluates the health status of a custom resource object.
+type StatusEvaluator struct {
+ current *Expression
+ failed *Expression
+ inProgress *Expression
+}
+
+// NewStatusEvaluator returns a new StatusEvaluator.
+func NewStatusEvaluator(exprs *kustomize.HealthCheckExpressions) (*StatusEvaluator, error) {
+ // we can't use the options WithCompile and WithStructVariables here
+ // because not all CRDs follow the standard five top-level fields:
+ // apiVersion, kind, metadata, spec and status
+ // and because we can't use WithCompile, we also can't use
+ // WithOutputType(celgo.BoolType)
+
+ if exprs.Current == "" {
+ return nil, fmt.Errorf("expression Current not specified")
+ }
+ current, err := NewExpression(exprs.Current)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse the expression Current: %w", err)
+ }
+
+ var failed *Expression
+ if exprs.Failed != "" {
+ failed, err = NewExpression(exprs.Failed)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse the expression Failed: %w", err)
+ }
+ }
+
+ var inProgress *Expression
+ if exprs.InProgress != "" {
+ inProgress, err = NewExpression(exprs.InProgress)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse the expression InProgress: %w", err)
+ }
+ }
+
+ return &StatusEvaluator{
+ current: current,
+ failed: failed,
+ inProgress: inProgress,
+ }, nil
+}
+
+// Evaluate evaluates the health status of a custom resource object
+// according to the rules defined in RFC 0009:
+//
+// First we check if the object has the field status.observedGeneration. If it does,
+// and the value is different from metadata.generation, we return the status InProgress.
+//
+// Then we evaluate the healthcheck expressions in the following order:
+// - InProgress: if true, return status InProgress
+// - Failed: if true, return status Failed
+// - Current: if true, return status Current
+//
+// If none of the expressions are true, we return status InProgress.
+func (s *StatusEvaluator) Evaluate(ctx context.Context, u *unstructured.Unstructured) (*status.Result, error) {
+ unsObj := u.UnstructuredContent()
+
+ // Check if the object has the field status.observedGeneration
+ // and if it differs from metadata.generation, in which case we
+ // return status InProgress.
+ observedGeneration, ok, err := unstructured.NestedInt64(unsObj, "status", "observedGeneration")
+ if err != nil {
+ return nil, err
+ }
+ if ok {
+ generation, ok, err := unstructured.NestedInt64(unsObj, "metadata", "generation")
+ if err != nil {
+ return nil, err
+ }
+ if ok && observedGeneration != generation {
+ return &status.Result{Status: status.InProgressStatus}, nil
+ }
+ }
+
+ // Evaluate the healthcheck expressions.
+ for _, e := range []struct {
+ expr *Expression
+ status status.Status
+ }{
+ // This order is defined in RFC 0009.
+ {expr: s.inProgress, status: status.InProgressStatus},
+ {expr: s.failed, status: status.FailedStatus},
+ {expr: s.current, status: status.CurrentStatus},
+ } {
+ if e.expr == nil {
+ continue
+ }
+ result, err := e.expr.EvaluateBoolean(ctx, unsObj)
+ if err != nil {
+ return nil, err
+ }
+ if result {
+ return &status.Result{Status: e.status}, nil
+ }
+ }
+
+ return &status.Result{Status: status.InProgressStatus}, nil
+}
diff --git a/runtime/cel/status_evaluator_test.go b/runtime/cel/status_evaluator_test.go
new file mode 100644
index 00000000..f642e8a2
--- /dev/null
+++ b/runtime/cel/status_evaluator_test.go
@@ -0,0 +1,336 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ . "github.com/onsi/gomega"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+
+ "github.com/fluxcd/pkg/apis/kustomize"
+ "github.com/fluxcd/pkg/runtime/cel"
+)
+
+func TestNewStatusEvaluator(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ exprs kustomize.HealthCheckExpressions
+ err string
+ }{
+ {
+ name: "all expressions are present",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.inProgress",
+ Failed: "data.failed",
+ },
+ },
+ {
+ name: "InProgress and Failed are optional",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ },
+ },
+ {
+ name: "Current is required",
+ err: "expression Current not specified",
+ },
+ {
+ name: "errors if Current is invalid",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.",
+ },
+ err: "failed to parse the expression Current",
+ },
+ {
+ name: "errors if InProgress is invalid",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.",
+ },
+ err: "failed to parse the expression InProgress",
+ },
+ {
+ name: "errors if Failed is invalid",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ Failed: "data.",
+ },
+ err: "failed to parse the expression Failed",
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ g := NewWithT(t)
+
+ s, err := cel.NewStatusEvaluator(&tt.exprs)
+
+ if tt.err != "" {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring(tt.err))
+ g.Expect(s).To(BeNil())
+ } else {
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(s).NotTo(BeNil())
+ }
+ })
+ }
+}
+
+func TestStatusEvaluator_Evaluate(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ exprs kustomize.HealthCheckExpressions
+ obj map[string]any
+ result status.Result
+ err string
+ }{
+ {
+ name: "observed generation exists and is different",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "true",
+ },
+ obj: map[string]any{
+ "metadata": map[string]any{
+ "generation": int64(2),
+ },
+ "status": map[string]any{
+ "observedGeneration": int64(1),
+ },
+ },
+ result: status.Result{Status: status.InProgressStatus},
+ },
+ {
+ name: "if Current returns an error, the error is returned",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.currentt",
+ InProgress: "data.inProgress",
+ Failed: "data.failed",
+ },
+ obj: map[string]any{"data": map[string]any{
+ "current": true,
+ "inProgress": false,
+ "failed": false,
+ }},
+ err: "failed to evaluate the CEL expression 'data.currentt': no such key: currentt",
+ },
+ {
+ name: "if InProgress returns an error, the error is returned",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.inProgresss",
+ Failed: "data.failed",
+ },
+ obj: map[string]any{"data": map[string]any{
+ "current": true,
+ "inProgress": false,
+ "failed": false,
+ }},
+ err: "failed to evaluate the CEL expression 'data.inProgresss': no such key: inProgresss",
+ },
+ {
+ name: "if Failed returns an error, the error is returned",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.inProgress",
+ Failed: "data.failedd",
+ },
+ obj: map[string]any{"data": map[string]any{
+ "current": true,
+ "inProgress": false,
+ "failed": false,
+ }},
+ err: "failed to evaluate the CEL expression 'data.failedd': no such key: failedd",
+ },
+ {
+ name: "if all expressions evaluate to false then the object is in progress",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.inProgress",
+ Failed: "data.failed",
+ },
+ obj: map[string]any{"data": map[string]any{
+ "current": false,
+ "inProgress": false,
+ "failed": false,
+ }},
+ result: status.Result{Status: status.InProgressStatus},
+ },
+ {
+ name: "if all expressions evaluate to true then the object is in progress",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.inProgress",
+ Failed: "data.failed",
+ },
+ obj: map[string]any{"data": map[string]any{
+ "current": true,
+ "inProgress": true,
+ "failed": true,
+ }},
+ result: status.Result{Status: status.InProgressStatus},
+ },
+ {
+ name: "if both Current and Failed evaluate to true, then the object failed",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.inProgress",
+ Failed: "data.failed",
+ },
+ obj: map[string]any{"data": map[string]any{
+ "current": true,
+ "inProgress": false,
+ "failed": true,
+ }},
+ result: status.Result{Status: status.FailedStatus},
+ },
+ {
+ name: "if only Current evaluates to true, then the object is current",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ InProgress: "data.inProgress",
+ Failed: "data.failed",
+ },
+ obj: map[string]any{"data": map[string]any{
+ "current": true,
+ "inProgress": false,
+ "failed": false,
+ }},
+ result: status.Result{Status: status.CurrentStatus},
+ },
+ {
+ name: "object without status with inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ InProgress: "has(status.observedGeneration) && metadata.generation != status.observedGeneration",
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{},
+ err: "failed to evaluate the CEL expression 'has(status.observedGeneration) && metadata.generation != status.observedGeneration': no such attribute(s): status.observedGeneration",
+ },
+ {
+ name: "object without status without inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{},
+ err: "failed to evaluate the CEL expression 'status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')': no such attribute(s): status.conditions",
+ },
+ {
+ name: "object with status without status.conditions with inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ InProgress: "has(status.observedGeneration) && metadata.generation != status.observedGeneration",
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{"status": map[string]any{}},
+ err: "failed to evaluate the CEL expression 'status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')': no such key: conditions",
+ },
+ {
+ name: "object with status without status.conditions without inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{"status": map[string]any{}},
+ err: "failed to evaluate the CEL expression 'status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')': no such key: conditions",
+ },
+ {
+ name: "object with status with empty status.conditions with inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ InProgress: "has(status.observedGeneration) && metadata.generation != status.observedGeneration",
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{
+ "status": map[string]any{
+ "conditions": []any{},
+ },
+ },
+ result: status.Result{Status: status.FailedStatus},
+ },
+ {
+ name: "object with status with empty status.conditions without inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{
+ "status": map[string]any{
+ "conditions": []any{},
+ },
+ },
+ result: status.Result{Status: status.FailedStatus},
+ },
+ {
+ name: "object with status.observedGeneration with inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ InProgress: "has(status.observedGeneration) && metadata.generation != status.observedGeneration",
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{
+ "metadata": map[string]any{
+ "generation": int64(2),
+ },
+ "status": map[string]any{
+ "observedGeneration": int64(1),
+ },
+ },
+ result: status.Result{Status: status.InProgressStatus},
+ },
+ {
+ name: "object with status.observedGeneration without inProgress expression",
+ exprs: kustomize.HealthCheckExpressions{
+ Failed: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Ready').all(e, e.status == 'True')",
+ },
+ obj: map[string]any{
+ "metadata": map[string]any{
+ "generation": int64(2),
+ },
+ "status": map[string]any{
+ "observedGeneration": int64(1),
+ },
+ },
+ result: status.Result{Status: status.InProgressStatus},
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ g := NewWithT(t)
+
+ e, err := cel.NewStatusEvaluator(&tt.exprs)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ result, err := e.Evaluate(context.Background(), &unstructured.Unstructured{Object: tt.obj})
+ if tt.err != "" {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring(tt.err))
+ } else {
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(*result).To(Equal(tt.result))
+ }
+ })
+ }
+}
diff --git a/runtime/cel/status_poller.go b/runtime/cel/status_poller.go
new file mode 100644
index 00000000..69349e1a
--- /dev/null
+++ b/runtime/cel/status_poller.go
@@ -0,0 +1,63 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/fluxcd/pkg/apis/kustomize"
+)
+
+// PollerWithCustomHealthChecks extends the polling.Options with custom
+// status readers for the given healthchecks. If there are multiple
+// healthchecks defined for the same GroupKind, only the first one
+// is used. The context is used to control the execution of the
+// underlying status readers.
+func PollerWithCustomHealthChecks(ctx context.Context,
+ base polling.Options,
+ healthchecks []kustomize.CustomHealthCheck,
+ mapper meta.RESTMapper) (polling.Options, error) {
+
+ if len(healthchecks) == 0 {
+ return base, nil
+ }
+
+ readers := make([]engine.StatusReader, 0, len(healthchecks))
+ types := make(map[schema.GroupKind]struct{}, len(healthchecks))
+ for i, hc := range healthchecks {
+ gk := schema.FromAPIVersionAndKind(hc.APIVersion, hc.Kind).GroupKind()
+ if _, ok := types[gk]; !ok {
+ sr, err := NewStatusReader(ctx, mapper, gk, &hc.HealthCheckExpressions)
+ if err != nil {
+ return polling.Options{}, fmt.Errorf(
+ "failed to create custom status reader for healthchecks[%d]: %w", i, err)
+ }
+ readers = append(readers, sr)
+ types[gk] = struct{}{}
+ }
+ }
+
+ base.CustomStatusReaders = append(base.CustomStatusReaders, readers...)
+
+ return base, nil
+}
diff --git a/runtime/cel/status_poller_test.go b/runtime/cel/status_poller_test.go
new file mode 100644
index 00000000..e455a44d
--- /dev/null
+++ b/runtime/cel/status_poller_test.go
@@ -0,0 +1,206 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ "github.com/fluxcd/cli-utils/pkg/object"
+ . "github.com/onsi/gomega"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ "github.com/fluxcd/pkg/apis/kustomize"
+ "github.com/fluxcd/pkg/runtime/cel"
+)
+
+func TestPollerWithCustomHealthChecks(t *testing.T) {
+ g := NewWithT(t)
+
+ var emptyOpts polling.Options
+
+ result, err := cel.PollerWithCustomHealthChecks(context.Background(), emptyOpts, []kustomize.CustomHealthCheck{
+ {
+ APIVersion: "v1",
+ Kind: "ConfigMap",
+ HealthCheckExpressions: kustomize.HealthCheckExpressions{
+ Current: "something",
+ },
+ },
+ {
+ APIVersion: "v1",
+ Kind: "ConfigMap",
+ HealthCheckExpressions: kustomize.HealthCheckExpressions{
+ Current: "something",
+ },
+ },
+ }, nil)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ g.Expect(result.CustomStatusReaders).To(HaveLen(1))
+
+ r := result.CustomStatusReaders[0]
+ g.Expect(r).NotTo(BeNil())
+
+ supports := r.Supports(schema.GroupKind{
+ Group: "",
+ Kind: "ConfigMap",
+ })
+ g.Expect(supports).To(BeTrue())
+
+ doesNotSupport := r.Supports(schema.GroupKind{
+ Group: "",
+ Kind: "Pod",
+ })
+ g.Expect(doesNotSupport).To(BeFalse())
+}
+
+func TestPollerWithCustomHealthChecksError(t *testing.T) {
+ g := NewWithT(t)
+
+ var emptyOpts polling.Options
+
+ result, err := cel.PollerWithCustomHealthChecks(context.Background(), emptyOpts, []kustomize.CustomHealthCheck{{
+ APIVersion: "v1",
+ Kind: "ConfigMap",
+ HealthCheckExpressions: kustomize.HealthCheckExpressions{
+ Current: "something.",
+ },
+ }}, nil)
+
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring("failed to create custom status reader for healthchecks[0]"))
+ g.Expect(result).To(Equal(emptyOpts))
+}
+
+func TestStatusPoller_CustomResourceLifeCycle(t *testing.T) {
+ g := NewWithT(t)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ ns, err := testEnv.CreateNamespace(ctx, "test-namespace")
+ g.Expect(err).NotTo(HaveOccurred())
+ objNamespace := ns.Name
+
+ err = testEnv.Create(ctx, &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "bitnami.com/v1alpha1",
+ "kind": "SealedSecret",
+ "metadata": map[string]any{
+ "name": "test-sealedsecret",
+ "namespace": objNamespace,
+ },
+ "spec": map[string]any{
+ "encryptedData": map[string]any{
+ "foo": "c2VjcmV0",
+ },
+ },
+ },
+ })
+ g.Expect(err).NotTo(HaveOccurred())
+
+ healthchecks := []kustomize.CustomHealthCheck{{
+ APIVersion: "bitnami.com/v1alpha1",
+ Kind: "SealedSecret",
+ HealthCheckExpressions: kustomize.HealthCheckExpressions{
+ InProgress: "has(status.observedGeneration) && status.observedGeneration != metadata.generation",
+ Failed: "status.conditions.filter(e, e.type == 'Synced').all(e, e.status == 'False')",
+ Current: "status.conditions.filter(e, e.type == 'Synced').all(e, e.status == 'True')",
+ },
+ }}
+
+ identifiers := object.ObjMetadataSet{{
+ Name: "test-sealedsecret",
+ Namespace: objNamespace,
+ GroupKind: schema.GroupKind{
+ Group: "bitnami.com",
+ Kind: "SealedSecret",
+ },
+ }}
+
+ mapper := testEnv.GetRESTMapper()
+
+ opts, err := cel.PollerWithCustomHealthChecks(context.Background(), polling.Options{}, healthchecks, mapper)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ poller := polling.NewStatusPoller(testEnv.GetClient(), mapper, opts)
+ events := poller.Poll(ctx, identifiers, polling.PollOptions{
+ PollInterval: 100 * time.Millisecond,
+ })
+
+ // No status at first. Our InProgress expression returns an error, the
+ // status should be Unknown.
+ event := waitForEvent(t, ctx, events)
+ g.Expect(event.Resource.Status).To(Equal(status.UnknownStatus))
+
+ // Controller adds status.observedGeneration, the status should be InProgress.
+ u := &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "bitnami.com/v1alpha1",
+ "kind": "SealedSecret",
+ },
+ }
+ err = testEnv.Get(ctx, client.ObjectKey{Name: "test-sealedsecret", Namespace: objNamespace}, u)
+ g.Expect(err).NotTo(HaveOccurred())
+ u.Object["status"] = map[string]any{
+ "observedGeneration": u.GetGeneration() - 1,
+ }
+ err = testEnv.Status().Update(ctx, u)
+ g.Expect(err).NotTo(HaveOccurred())
+ event = waitForEvent(t, ctx, events)
+ g.Expect(event.Resource.Status).To(Equal(status.InProgressStatus))
+
+ // Controller adds Synced=True, the status should be Current.
+ u.Object["status"] = map[string]any{
+ "observedGeneration": u.GetGeneration(),
+ "conditions": []map[string]any{{"type": "Synced", "status": "True"}},
+ }
+ err = testEnv.Status().Update(ctx, u)
+ g.Expect(err).NotTo(HaveOccurred())
+ event = waitForEvent(t, ctx, events)
+ g.Expect(event.Resource.Status).To(Equal(status.CurrentStatus))
+
+ // Controller adds Synced=False, the status should be Failed.
+ u.Object["status"] = map[string]any{
+ "observedGeneration": u.GetGeneration(),
+ "conditions": []map[string]any{{"type": "Synced", "status": "False"}},
+ }
+ err = testEnv.Status().Update(ctx, u)
+ g.Expect(err).NotTo(HaveOccurred())
+ event = waitForEvent(t, ctx, events)
+ g.Expect(event.Resource.Status).To(Equal(status.FailedStatus))
+}
+
+func waitForEvent(t *testing.T, ctx context.Context, events <-chan event.Event) *event.Event {
+ t.Helper()
+
+ select {
+ case e := <-events:
+ return &e
+ case <-ctx.Done():
+ t.Errorf("timed out waiting for event")
+ t.FailNow()
+ return nil
+ }
+}
diff --git a/runtime/cel/status_reader.go b/runtime/cel/status_reader.go
new file mode 100644
index 00000000..5cc4768b
--- /dev/null
+++ b/runtime/cel/status_reader.go
@@ -0,0 +1,78 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel
+
+import (
+ "context"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/event"
+ kstatusreaders "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ "github.com/fluxcd/cli-utils/pkg/object"
+ "k8s.io/apimachinery/pkg/api/meta"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/fluxcd/pkg/apis/kustomize"
+)
+
+// StatusReader implements the engine.StatusReader interface for a specific GroupKind and
+// set of healthcheck expressions.
+type StatusReader struct {
+ genericStatusReader engine.StatusReader
+ gk schema.GroupKind
+}
+
+// NewStatusReader returns a new StatusReader for the given GroupKind and healthcheck expressions.
+// The context is used to control the execution of the underlying operations performed by the
+// the reader.
+func NewStatusReader(ctx context.Context, mapper meta.RESTMapper, gk schema.GroupKind,
+ exprs *kustomize.HealthCheckExpressions) (engine.StatusReader, error) {
+
+ s, err := NewStatusEvaluator(exprs)
+ if err != nil {
+ return nil, err
+ }
+
+ statusFunc := func(u *unstructured.Unstructured) (*status.Result, error) {
+ return s.Evaluate(ctx, u)
+ }
+
+ genericStatusReader := kstatusreaders.NewGenericStatusReader(mapper, statusFunc)
+ return &StatusReader{
+ genericStatusReader: genericStatusReader,
+ gk: gk,
+ }, nil
+}
+
+// Supports returns true if the StatusReader supports the given GroupKind.
+func (g *StatusReader) Supports(gk schema.GroupKind) bool {
+ return gk == g.gk
+}
+
+// ReadStatus reads the status of the resource with the given metadata.
+func (g *StatusReader) ReadStatus(ctx context.Context, reader engine.ClusterReader,
+ resource object.ObjMetadata) (*event.ResourceStatus, error) {
+ return g.genericStatusReader.ReadStatus(ctx, reader, resource)
+}
+
+// ReadStatusForObject reads the status of the given resource.
+func (g *StatusReader) ReadStatusForObject(ctx context.Context, reader engine.ClusterReader,
+ resource *unstructured.Unstructured) (*event.ResourceStatus, error) {
+ return g.genericStatusReader.ReadStatusForObject(ctx, reader, resource)
+}
diff --git a/runtime/cel/status_reader_test.go b/runtime/cel/status_reader_test.go
new file mode 100644
index 00000000..d04c2050
--- /dev/null
+++ b/runtime/cel/status_reader_test.go
@@ -0,0 +1,264 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/clusterreader"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/status"
+ "github.com/fluxcd/cli-utils/pkg/object"
+ . "github.com/onsi/gomega"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+
+ "github.com/fluxcd/pkg/apis/kustomize"
+ "github.com/fluxcd/pkg/runtime/cel"
+)
+
+func TestNewStatusReader(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ exprs kustomize.HealthCheckExpressions
+ err string
+ }{
+ {
+ name: "success",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "something",
+ InProgress: "something",
+ Failed: "something",
+ },
+ },
+ {
+ name: "errors if object evaluator constructor errors",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "something.",
+ },
+ err: "failed to parse the expression",
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ g := NewWithT(t)
+
+ sr, err := cel.NewStatusReader(context.Background(), nil, schema.GroupKind{}, &tt.exprs)
+
+ if tt.err != "" {
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(ContainSubstring(tt.err))
+ g.Expect(sr).To(BeNil())
+ } else {
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(sr).NotTo(BeNil())
+ }
+ })
+ }
+}
+
+func TestStatusReader_Supports(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ supportedGK schema.GroupKind
+ gk schema.GroupKind
+ result bool
+ }{
+ {
+ name: "supported",
+ supportedGK: schema.GroupKind{
+ Group: "test",
+ Kind: "Test",
+ },
+ gk: schema.GroupKind{
+ Group: "test",
+ Kind: "Test",
+ },
+ result: true,
+ },
+ {
+ name: "unsupported",
+ supportedGK: schema.GroupKind{
+ Group: "test",
+ Kind: "Test",
+ },
+ gk: schema.GroupKind{
+ Group: "test",
+ Kind: "Unsupported",
+ },
+ result: false,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ g := NewWithT(t)
+
+ sr, err := cel.NewStatusReader(context.Background(), nil, tt.supportedGK, &kustomize.HealthCheckExpressions{
+ Current: "something",
+ })
+ g.Expect(err).NotTo(HaveOccurred())
+
+ result := sr.Supports(tt.gk)
+ g.Expect(result).To(Equal(tt.result))
+ })
+ }
+}
+
+func TestStatusReader_ReadStatus(t *testing.T) {
+ g := NewWithT(t)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ mapper := testEnv.GetRESTMapper()
+ clusterReader := &clusterreader.DirectClusterReader{Reader: testEnv.GetClient()}
+
+ gk := schema.GroupKind{
+ Group: "",
+ Kind: "ConfigMap",
+ }
+
+ ns, err := testEnv.CreateNamespace(ctx, "test-namespace")
+ g.Expect(err).NotTo(HaveOccurred())
+ objNamespace := ns.Name
+
+ const objName = "test-configmap"
+ err = testEnv.Create(ctx, &corev1.ConfigMap{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: objName,
+ Namespace: objNamespace,
+ },
+ Data: map[string]string{
+ "current": "true",
+ "inProgress": "true",
+ "failed": "true",
+ },
+ })
+ g.Expect(err).NotTo(HaveOccurred())
+
+ for _, tt := range []struct {
+ name string
+ exprs kustomize.HealthCheckExpressions
+ status status.Status
+ }{
+ {
+ name: "current",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current == 'true'",
+ },
+ status: status.CurrentStatus,
+ },
+ {
+ name: "in progress",
+ exprs: kustomize.HealthCheckExpressions{
+ InProgress: "data.inProgress == 'true'",
+ Current: "data.current == 'true'",
+ },
+ status: status.InProgressStatus,
+ },
+ {
+ name: "failed",
+ exprs: kustomize.HealthCheckExpressions{
+ Failed: "data.failed == 'true'",
+ Current: "data.current == 'true'",
+ },
+ status: status.FailedStatus,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ g := NewWithT(t)
+
+ sr, err := cel.NewStatusReader(context.Background(), mapper, gk, &tt.exprs)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ result, err := sr.ReadStatus(ctx, clusterReader, object.ObjMetadata{
+ Name: objName,
+ Namespace: objNamespace,
+ GroupKind: gk,
+ })
+
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(result.Status).To(Equal(tt.status))
+ })
+ }
+}
+
+func TestStatusReader_ReadStatusForObject(t *testing.T) {
+ gk := schema.GroupKind{
+ Group: "",
+ Kind: "ConfigMap",
+ }
+
+ for _, tt := range []struct {
+ name string
+ exprs kustomize.HealthCheckExpressions
+ status status.Status
+ }{
+ {
+ name: "current",
+ exprs: kustomize.HealthCheckExpressions{
+ Current: "data.current",
+ },
+ status: status.CurrentStatus,
+ },
+ {
+ name: "in progress",
+ exprs: kustomize.HealthCheckExpressions{
+ InProgress: "data.inProgress",
+ Current: "data.current",
+ },
+ status: status.InProgressStatus,
+ },
+ {
+ name: "failed",
+ exprs: kustomize.HealthCheckExpressions{
+ Failed: "data.failed",
+ Current: "data.current",
+ },
+ status: status.FailedStatus,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ g := NewWithT(t)
+
+ sr, err := cel.NewStatusReader(context.Background(), nil, gk, &tt.exprs)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ result, err := sr.ReadStatusForObject(context.Background(), nil, &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "data": map[string]any{
+ "current": true,
+ "inProgress": true,
+ "failed": true,
+ },
+ },
+ })
+
+ g.Expect(err).NotTo(HaveOccurred())
+ g.Expect(result.Status).To(Equal(tt.status))
+ })
+ }
+}
diff --git a/runtime/cel/suite_test.go b/runtime/cel/suite_test.go
new file mode 100644
index 00000000..ad80e25a
--- /dev/null
+++ b/runtime/cel/suite_test.go
@@ -0,0 +1,51 @@
+/*
+Copyright 2025 The Flux authors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package cel_test
+
+import (
+ "fmt"
+ "os"
+ "testing"
+
+ ctrl "sigs.k8s.io/controller-runtime"
+
+ "github.com/fluxcd/pkg/runtime/testenv"
+)
+
+var testEnv *testenv.Environment
+
+func TestMain(m *testing.M) {
+ testEnv = testenv.New(testenv.WithCRDPath("testdata/crds"))
+
+ ctx := ctrl.SetupSignalHandler()
+ go func() {
+ fmt.Println("Starting the test environment")
+ if err := testEnv.Start(ctx); err != nil {
+ panic(fmt.Sprintf("Failed to start the test environment manager: %v", err))
+ }
+ }()
+ <-testEnv.Manager.Elected()
+
+ code := m.Run()
+
+ fmt.Println("Stopping the test environment")
+ if err := testEnv.Stop(); err != nil {
+ panic(fmt.Sprintf("Failed to stop the test environment: %v", err))
+ }
+
+ os.Exit(code)
+}
diff --git a/runtime/cel/testdata/crds/sealedsecrets.yaml b/runtime/cel/testdata/crds/sealedsecrets.yaml
new file mode 100644
index 00000000..d40dc39b
--- /dev/null
+++ b/runtime/cel/testdata/crds/sealedsecrets.yaml
@@ -0,0 +1,170 @@
+# Vendored from:
+# https://github.com/bitnami-labs/sealed-secrets/blob/fe292af10a0aab67bf31bff0e78414dde43a6981/helm/sealed-secrets/crds/bitnami.com_sealedsecrets.yaml
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.15.0
+ name: sealedsecrets.bitnami.com
+spec:
+ group: bitnami.com
+ names:
+ kind: SealedSecret
+ listKind: SealedSecretList
+ plural: sealedsecrets
+ singular: sealedsecret
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .status.conditions[0].message
+ name: Status
+ type: string
+ - jsonPath: .status.conditions[0].status
+ name: Synced
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: |-
+ SealedSecret is the K8s representation of a "sealed Secret" - a
+ regular k8s Secret that has been sealed (encrypted) using the
+ controller's key.
+ 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
+ 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
+ spec:
+ description: SealedSecretSpec is the specification of a SealedSecret.
+ properties:
+ data:
+ description: Data is deprecated and will be removed eventually. Use
+ per-value EncryptedData instead.
+ format: byte
+ type: string
+ encryptedData:
+ additionalProperties:
+ type: string
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ template:
+ description: |-
+ Template defines the structure of the Secret that will be
+ created from this sealed secret.
+ properties:
+ data:
+ additionalProperties:
+ type: string
+ description: Keys that should be templated using decrypted data.
+ nullable: true
+ type: object
+ immutable:
+ description: |-
+ Immutable, if set to true, ensures that data stored in the Secret cannot
+ be updated (only object metadata can be modified).
+ If not set to true, the field can be modified at any time.
+ Defaulted to nil.
+ type: boolean
+ metadata:
+ description: |-
+ Standard object's metadata.
+ More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#metadata
+ nullable: true
+ properties:
+ annotations:
+ additionalProperties:
+ type: string
+ type: object
+ finalizers:
+ items:
+ type: string
+ type: array
+ labels:
+ additionalProperties:
+ type: string
+ type: object
+ name:
+ type: string
+ namespace:
+ type: string
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ type:
+ description: Used to facilitate programmatic handling of secret
+ data.
+ type: string
+ type: object
+ required:
+ - encryptedData
+ type: object
+ status:
+ description: SealedSecretStatus is the most recently observed status of
+ the SealedSecret.
+ properties:
+ conditions:
+ description: Represents the latest available observations of a sealed
+ secret's current state.
+ items:
+ description: SealedSecretCondition describes the state of a sealed
+ secret at a certain point.
+ properties:
+ lastTransitionTime:
+ description: Last time the condition transitioned from one status
+ to another.
+ format: date-time
+ type: string
+ lastUpdateTime:
+ description: The last time this condition was updated.
+ format: date-time
+ type: string
+ message:
+ description: A human readable message indicating details about
+ the transition.
+ type: string
+ reason:
+ description: The reason for the condition's last transition.
+ type: string
+ status:
+ description: |-
+ Status of the condition for a sealed secret.
+ Valid values for "Synced": "True", "False", or "Unknown".
+ type: string
+ type:
+ description: |-
+ Type of condition for a sealed secret.
+ Valid value: "Synced"
+ type: string
+ required:
+ - status
+ - type
+ type: object
+ type: array
+ observedGeneration:
+ description: ObservedGeneration reflects the generation most recently
+ observed by the sealed-secrets controller.
+ format: int64
+ type: integer
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/runtime/go.mod b/runtime/go.mod
index 8540b252..367a4090 100644
--- a/runtime/go.mod
+++ b/runtime/go.mod
@@ -5,16 +5,19 @@ go 1.23.0
replace (
github.com/fluxcd/pkg/apis/acl => ../apis/acl
github.com/fluxcd/pkg/apis/event => ../apis/event
+ github.com/fluxcd/pkg/apis/kustomize => ../apis/kustomize
github.com/fluxcd/pkg/apis/meta => ../apis/meta
)
require (
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6
github.com/fluxcd/cli-utils v0.36.0-flux.12
- github.com/fluxcd/pkg/apis/acl v0.5.0
- github.com/fluxcd/pkg/apis/event v0.15.0
- github.com/fluxcd/pkg/apis/meta v1.9.0
+ github.com/fluxcd/pkg/apis/acl v0.6.0
+ github.com/fluxcd/pkg/apis/event v0.16.0
+ github.com/fluxcd/pkg/apis/kustomize v1.9.0
+ github.com/fluxcd/pkg/apis/meta v1.10.0
github.com/go-logr/logr v1.4.2
+ github.com/google/cel-go v0.23.1
github.com/google/go-cmp v0.6.0
github.com/hashicorp/go-retryablehttp v0.7.7
github.com/kylelemons/godebug v1.1.0
@@ -38,8 +41,10 @@ require (
replace gopkg.in/yaml.v3 => gopkg.in/yaml.v3 v3.0.1
require (
+ cel.dev/expr v0.19.1 // indirect
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/MakeNowJust/heredoc v1.0.0 // indirect
+ github.com/antlr4-go/antlr/v4 v4.13.0 // 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
@@ -86,9 +91,11 @@ require (
github.com/prometheus/procfs v0.15.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/cobra v1.8.1 // indirect
+ github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
@@ -96,6 +103,8 @@ require (
golang.org/x/text v0.21.0 // indirect
golang.org/x/time v0.9.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
diff --git a/runtime/go.sum b/runtime/go.sum
index 0abc4597..e5d3719e 100644
--- a/runtime/go.sum
+++ b/runtime/go.sum
@@ -1,9 +1,13 @@
+cel.dev/expr v0.19.1 h1:NciYrtDRIR0lNCnH1LFJegdjspNx9fI59O7TWcua/W4=
+cel.dev/expr v0.19.1/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+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/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -57,6 +61,8 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
+github.com/google/cel-go v0.23.1 h1:91ThhEZlBcE5rB2adBVXqvDoqdL8BG2oyhd0bK1I/r4=
+github.com/google/cel-go v0.23.1/go.mod h1:52Pb6QsDbC5kvgxvZhiL9QX1oZEkcUF/ZqaPx1J5Wwo=
github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw=
github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -152,12 +158,19 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/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=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -175,6 +188,8 @@ go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
+golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
@@ -216,6 +231,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
+google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:OCdP9MfskevB/rbYvHTsXTtKC+3bHWajPdoKgjcYkfo=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 h1:2035KHhUv+EpyB+hWgJnaWKJOdX1E95w2S8Rr4uWKTs=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/ssa/main_test.go b/ssa/main_test.go
index 2d20930e..a69e681a 100644
--- a/ssa/main_test.go
+++ b/ssa/main_test.go
@@ -24,6 +24,7 @@ import (
"sync/atomic"
"testing"
+ "k8s.io/apimachinery/pkg/api/meta"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -35,7 +36,10 @@ import (
"github.com/fluxcd/pkg/ssa/utils"
)
-var manager *ResourceManager
+var (
+ manager *ResourceManager
+ restMapper meta.RESTMapper
+)
func TestMain(m *testing.M) {
testEnv := &envtest.Environment{}
@@ -49,7 +53,7 @@ func TestMain(m *testing.M) {
if err != nil {
panic(err)
}
- restMapper, err := apiutil.NewDynamicRESTMapper(cfg, httpClient)
+ restMapper, err = apiutil.NewDynamicRESTMapper(cfg, httpClient)
if err != nil {
panic(err)
}
diff --git a/ssa/manager_wait_test.go b/ssa/manager_wait_test.go
index 29726753..6e4f7b8c 100644
--- a/ssa/manager_wait_test.go
+++ b/ssa/manager_wait_test.go
@@ -24,6 +24,7 @@ import (
"testing"
"time"
+ . "github.com/onsi/gomega"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -31,6 +32,9 @@ import (
"k8s.io/apimachinery/pkg/runtime/schema"
"sigs.k8s.io/controller-runtime/pkg/client"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling"
+ "github.com/fluxcd/cli-utils/pkg/kstatus/polling/engine"
+ kstatusreaders "github.com/fluxcd/cli-utils/pkg/kstatus/polling/statusreaders"
"github.com/fluxcd/cli-utils/pkg/kstatus/status"
"github.com/fluxcd/cli-utils/pkg/object"
@@ -231,3 +235,45 @@ func TestWaitForSet_failFast(t *testing.T) {
}
})
}
+
+func TestWaitForSet_ErrorOnReaderError(t *testing.T) {
+ g := NewWithT(t)
+
+ err := manager.client.Create(context.Background(), &unstructured.Unstructured{
+ Object: map[string]any{
+ "apiVersion": "v1",
+ "kind": "ConfigMap",
+ "metadata": map[string]any{
+ "name": "test",
+ "namespace": "default",
+ },
+ "data": map[string]any{
+ "foo": "bar",
+ },
+ },
+ })
+ g.Expect(err).NotTo(HaveOccurred())
+
+ manager.poller = polling.NewStatusPoller(manager.client, restMapper, polling.Options{
+ CustomStatusReaders: []engine.StatusReader{
+ kstatusreaders.NewGenericStatusReader(restMapper,
+ func(*unstructured.Unstructured) (*status.Result, error) {
+ return nil, fmt.Errorf("error reading status")
+ },
+ ),
+ },
+ })
+
+ set := []object.ObjMetadata{{
+ Name: "test",
+ Namespace: "default",
+ GroupKind: schema.GroupKind{Group: "", Kind: "ConfigMap"},
+ }}
+ err = manager.WaitForSet(set, WaitOptions{
+ Interval: 40 * time.Millisecond,
+ Timeout: 100 * time.Millisecond,
+ })
+
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(err.Error()).To(Equal("timeout waiting for: [ConfigMap/default/test status: 'Unknown': error reading status]"))
+}