From 62d235c4b26b9e6cf9083e878528f786e850f220 Mon Sep 17 00:00:00 2001 From: Matheus Pimenta Date: Thu, 23 Jan 2025 12:17:20 +0000 Subject: [PATCH] Add CEL library with custom healthchecks to runtime Signed-off-by: Matheus Pimenta --- runtime/cel/doc.go | 18 + runtime/cel/expression.go | 138 ++++++++ runtime/cel/expression_test.go | 221 ++++++++++++ runtime/cel/status_evaluator.go | 130 +++++++ runtime/cel/status_evaluator_test.go | 336 +++++++++++++++++++ runtime/cel/status_poller.go | 63 ++++ runtime/cel/status_poller_test.go | 206 ++++++++++++ runtime/cel/status_reader.go | 78 +++++ runtime/cel/status_reader_test.go | 264 +++++++++++++++ runtime/cel/suite_test.go | 51 +++ runtime/cel/testdata/crds/sealedsecrets.yaml | 170 ++++++++++ runtime/go.mod | 15 +- runtime/go.sum | 19 ++ ssa/main_test.go | 8 +- ssa/manager_wait_test.go | 46 +++ 15 files changed, 1758 insertions(+), 5 deletions(-) create mode 100644 runtime/cel/doc.go create mode 100644 runtime/cel/expression.go create mode 100644 runtime/cel/expression_test.go create mode 100644 runtime/cel/status_evaluator.go create mode 100644 runtime/cel/status_evaluator_test.go create mode 100644 runtime/cel/status_poller.go create mode 100644 runtime/cel/status_poller_test.go create mode 100644 runtime/cel/status_reader.go create mode 100644 runtime/cel/status_reader_test.go create mode 100644 runtime/cel/suite_test.go create mode 100644 runtime/cel/testdata/crds/sealedsecrets.yaml 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]")) +}