From 4f57e9dcefd99ce35aff34df5436dcd09ad5a27b Mon Sep 17 00:00:00 2001 From: Per Goncalves da Silva Date: Tue, 10 Oct 2023 13:05:43 +0200 Subject: [PATCH] pull in probing package from PKO Signed-off-by: Per Goncalves da Silva --- internal/probing/cel.go | 65 +++++ internal/probing/cel_test.go | 124 ++++++++++ internal/probing/parse.go | 99 ++++++++ internal/probing/parse_test.go | 107 ++++++++ internal/probing/probe.go | 142 +++++++++++ internal/probing/probe_test.go | 429 +++++++++++++++++++++++++++++++++ internal/probing/selectors.go | 40 +++ 7 files changed, 1006 insertions(+) create mode 100644 internal/probing/cel.go create mode 100644 internal/probing/cel_test.go create mode 100644 internal/probing/parse.go create mode 100644 internal/probing/parse_test.go create mode 100644 internal/probing/probe.go create mode 100644 internal/probing/probe_test.go create mode 100644 internal/probing/selectors.go diff --git a/internal/probing/cel.go b/internal/probing/cel.go new file mode 100644 index 00000000..3a028a95 --- /dev/null +++ b/internal/probing/cel.go @@ -0,0 +1,65 @@ +package probing + +import ( + "errors" + "fmt" + + "github.com/google/cel-go/cel" + "github.com/google/cel-go/ext" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apiserver/pkg/cel/library" +) + +type celProbe struct { + Program cel.Program + Message string +} + +var ErrCELInvalidEvaluationType = errors.New( + "cel expression must evaluate to a bool") + +func newCELProbe(rule, message string) (*celProbe, error) { + env, err := cel.NewEnv( + cel.Variable("self", cel.DynType), + cel.HomogeneousAggregateLiterals(), + cel.EagerlyValidateDeclarations(true), + cel.DefaultUTCTimeZone(true), + + ext.Strings(ext.StringsVersion(0)), + library.URLs(), + library.Regex(), + library.Lists(), + ) + if err != nil { + return nil, fmt.Errorf("creating CEL env: %w", err) + } + + ast, issues := env.Compile(rule) + if issues != nil { + return nil, fmt.Errorf("compiling CEL: %w", issues.Err()) + } + if ast.OutputType() != cel.BoolType { + return nil, ErrCELInvalidEvaluationType + } + + prgm, err := env.Program(ast) + if err != nil { + return nil, fmt.Errorf("CEL program failed: %w", err) + } + + return &celProbe{ + Program: prgm, + Message: message, + }, nil +} + +func (p *celProbe) Probe(obj *unstructured.Unstructured) (success bool, message string) { + val, _, err := p.Program.Eval(map[string]any{ + "self": obj.Object, + }) + if err != nil { + return false, fmt.Sprintf("CEL program failed: %v", err) + } + + return val.Value().(bool), p.Message +} diff --git a/internal/probing/cel_test.go b/internal/probing/cel_test.go new file mode 100644 index 00000000..b0e220af --- /dev/null +++ b/internal/probing/cel_test.go @@ -0,0 +1,124 @@ +package probing + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func Test_newCELProbe(t *testing.T) { + t.Parallel() + + _, err := newCELProbe(`self.test`, "") + require.ErrorIs(t, err, ErrCELInvalidEvaluationType) +} + +func Test_celProbe(t *testing.T) { + t.Parallel() + tests := []struct { + name string + rule, message string + obj *unstructured.Unstructured + + success bool + }{ + { + name: "simple success", + rule: `self.metadata.name == "hans"`, + message: "aaaaaah!", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "hans", + }, + }, + }, + success: true, + }, + { + name: "simple failure", + rule: `self.metadata.name == "hans"`, + message: "aaaaaah!", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "name": "nothans", + }, + }, + }, + success: false, + }, + { + name: "OpenShift Route success simple", + rule: `self.status.ingress.all(i, i.conditions.all(c, c.type == "Ready" && c.status == "True"))`, + message: "aaaaaah!", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "test": []interface{}{"1", "2", "3"}, + "ingress": []interface{}{ + map[string]interface{}{ + "host": "hostname.xxx.xxx", + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, + }, + }, + }, + }, + }, + success: true, + }, + { + name: "OpenShift Route failure", + rule: `self.status.ingress.all(i, i.conditions.all(c, c.type == "Ready" && c.status == "True"))`, + message: "aaaaaah!", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "test": []interface{}{"1", "2", "3"}, + "ingress": []interface{}{ + map[string]interface{}{ + "host": "hostname.xxx.xxx", + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "True", + }, + }, + }, + map[string]interface{}{ + "host": "otherhost.xxx.xxx", + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Ready", + "status": "False", + }, + }, + }, + }, + }, + }, + }, + success: false, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + p, err := newCELProbe(test.rule, test.message) + require.NoError(t, err) + + success, outMsg := p.Probe(test.obj) + assert.Equal(t, test.success, success) + assert.Equal(t, test.message, outMsg) + }) + } +} diff --git a/internal/probing/parse.go b/internal/probing/parse.go new file mode 100644 index 00000000..4ecca8c2 --- /dev/null +++ b/internal/probing/parse.go @@ -0,0 +1,99 @@ +package probing + +import ( + "context" + "fmt" + rukpakv1alpha1 "github.com/operator-framework/rukpak/api/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// Parse takes a list of ObjectSetProbes (commonly defined within a ObjectSetPhaseSpec) +// and compiles a single Prober to test objects with. +func Parse(ctx context.Context, bdProbes []rukpakv1alpha1.BundleDeploymentProbe) (Prober, error) { + probeList := make(list, len(bdProbes)) + for i, bdProbe := range bdProbes { + var ( + probe Prober + err error + ) + probe, err = ParseProbes(ctx, bdProbe.Probes) + if err != nil { + return nil, fmt.Errorf("parsing probe #%d: %w", i, err) + } + probe, err = ParseSelector(ctx, bdProbe.Selector, probe) + if err != nil { + return nil, fmt.Errorf("parsing selector of probe #%d: %w", i, err) + } + probeList[i] = probe + } + return probeList, nil +} + +// ParseSelector reads a corev1alpha1.ProbeSelector and wraps a Prober, +// only executing the Prober when the selector criteria match. +func ParseSelector(_ context.Context, selector rukpakv1alpha1.ProbeSelector, probe Prober) (Prober, error) { + if selector.Kind != nil { + probe = &kindSelector{ + Prober: probe, + GroupKind: schema.GroupKind{ + Group: selector.Kind.Group, + Kind: selector.Kind.Kind, + }, + } + } + if selector.Selector != nil { + s, err := metav1.LabelSelectorAsSelector(selector.Selector) + if err != nil { + return nil, err + } + probe = &selectorSelector{ + Prober: probe, + Selector: s, + } + } + return probe, nil +} + +// ParseProbes takes a []corev1alpha1.Probe and compiles it into a Prober. +func ParseProbes(_ context.Context, probeSpecs []rukpakv1alpha1.Probe) (Prober, error) { + var probeList list + for _, probeSpec := range probeSpecs { + var ( + probe Prober + err error + ) + + switch { + case probeSpec.FieldsEqual != nil: + probe = &fieldsEqualProbe{ + FieldA: probeSpec.FieldsEqual.FieldA, + FieldB: probeSpec.FieldsEqual.FieldB, + } + + case probeSpec.Condition != nil: + probe = NewConditionProbe( + probeSpec.Condition.Type, + probeSpec.Condition.Status, + ) + + case probeSpec.CEL != nil: + probe, err = newCELProbe( + probeSpec.CEL.Rule, + probeSpec.CEL.Message, + ) + if err != nil { + return nil, err + } + + default: + // probe has no known config + continue + } + probeList = append(probeList, probe) + } + + // Always check .status.observedCondition, if present. + return &statusObservedGenerationProbe{Prober: probeList}, nil +} diff --git a/internal/probing/parse_test.go b/internal/probing/parse_test.go new file mode 100644 index 00000000..377b5618 --- /dev/null +++ b/internal/probing/parse_test.go @@ -0,0 +1,107 @@ +package probing + +import ( + "context" + rukpakv1alpha1 "github.com/operator-framework/rukpak/api/v1alpha1" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestParse(t *testing.T) { + t.Parallel() + ctx := context.Background() + kind := "Test" + group := "test-group" + osp := []rukpakv1alpha1.BundleDeploymentProbe{ + { + Selector: rukpakv1alpha1.ProbeSelector{ + Kind: &rukpakv1alpha1.BundleDeploymentProbeKindSpec{ + Kind: kind, + Group: group, + }, + }, + }, + } + + p, err := Parse(ctx, osp) + require.NoError(t, err) + require.IsType(t, list{}, p) + + if assert.Len(t, p, 1) { + list := p.(list) + require.IsType(t, &kindSelector{}, list[0]) + ks := list[0].(*kindSelector) + assert.Equal(t, kind, ks.Kind) + assert.Equal(t, group, ks.Group) + } +} + +func TestParseSelector(t *testing.T) { + t.Parallel() + ctx := context.Background() + p, err := ParseSelector(ctx, rukpakv1alpha1.ProbeSelector{ + Kind: &rukpakv1alpha1.BundleDeploymentProbeKindSpec{ + Kind: "Test", + Group: "test", + }, + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "test": "test123", + }, + }, + }, nil) + require.NoError(t, err) + require.IsType(t, &selectorSelector{}, p) + + ss := p.(*selectorSelector) + require.IsType(t, &kindSelector{}, ss.Prober) +} + +func TestParseProbes(t *testing.T) { + t.Parallel() + fep := rukpakv1alpha1.Probe{ + FieldsEqual: &rukpakv1alpha1.ProbeFieldsEqualSpec{ + FieldA: "asdf", + FieldB: "jkl;", + }, + } + cp := rukpakv1alpha1.Probe{ + Condition: &rukpakv1alpha1.ProbeConditionSpec{ + Type: "asdf", + Status: "asdf", + }, + } + cel := rukpakv1alpha1.Probe{ + CEL: &rukpakv1alpha1.ProbeCELSpec{ + Message: "test", + Rule: `self.metadata.name == "test"`, + }, + } + emptyConfigProbe := rukpakv1alpha1.Probe{} + + p, err := ParseProbes(context.Background(), []rukpakv1alpha1.Probe{ + fep, cp, cel, emptyConfigProbe, + }) + require.NoError(t, err) + // everything should be wrapped + require.IsType(t, &statusObservedGenerationProbe{}, p) + + ogProbe := p.(*statusObservedGenerationProbe) + nested := ogProbe.Prober + require.IsType(t, list{}, nested) + + if assert.Len(t, nested, 3) { + nestedList := nested.(list) + assert.Equal(t, &fieldsEqualProbe{ + FieldA: "asdf", + FieldB: "jkl;", + }, nestedList[0]) + assert.Equal(t, &conditionProbe{ + Type: "asdf", + Status: "asdf", + }, nestedList[1]) + } +} diff --git a/internal/probing/probe.go b/internal/probing/probe.go new file mode 100644 index 00000000..9695d6c7 --- /dev/null +++ b/internal/probing/probe.go @@ -0,0 +1,142 @@ +package probing + +import ( + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +type Prober interface { + Probe(obj *unstructured.Unstructured) (success bool, message string) +} + +type list []Prober + +var _ Prober = (list)(nil) + +func (p list) Probe(obj *unstructured.Unstructured) (success bool, message string) { + var messages []string + for _, probe := range p { + if success, message := probe.Probe(obj); !success { + messages = append(messages, message) + } + } + if len(messages) > 0 { + return false, strings.Join(messages, ", ") + } + return true, "" +} + +// conditionProbe checks if the object's condition is set and in a certain status. +type conditionProbe struct { + Type, Status string +} + +func NewConditionProbe(typeName, status string) Prober { + return &conditionProbe{ + Type: typeName, + Status: status, + } +} + +var _ Prober = (*conditionProbe)(nil) + +func (cp *conditionProbe) Probe(obj *unstructured.Unstructured) (success bool, message string) { + defer func() { + if success { + return + } + // add probed condition type and status as context to error message. + message = fmt.Sprintf("condition %q == %q: %s", cp.Type, cp.Status, message) + }() + + rawConditions, exist, err := unstructured.NestedFieldNoCopy( + obj.Object, "status", "conditions") + conditions, ok := rawConditions.([]interface{}) + if err != nil || !exist { + return false, "missing .status.conditions" + } + if !ok { + return false, "malformed" + } + + for _, condI := range conditions { + cond, ok := condI.(map[string]interface{}) + if !ok { + // no idea what this is supposed to be + return false, "malformed" + } + + if cond["type"] != cp.Type { + // not the type we are probing for + continue + } + + // Check the condition's observed generation, if set + if observedGeneration, ok, err := unstructured.NestedInt64( + cond, "observedGeneration", + ); err == nil && ok && observedGeneration != obj.GetGeneration() { + return false, "outdated" + } + + if cond["status"] == cp.Status { + return true, "" + } + return false, "wrong status" + } + return false, "not reported" +} + +// fieldsEqualProbe checks if the values of the fields under the given json paths are equal. +type fieldsEqualProbe struct { + FieldA, FieldB string +} + +var _ Prober = (*fieldsEqualProbe)(nil) + +func (fe *fieldsEqualProbe) Probe(obj *unstructured.Unstructured) (success bool, message string) { + fieldAPath := strings.Split(strings.Trim(fe.FieldA, "."), ".") + fieldBPath := strings.Split(strings.Trim(fe.FieldB, "."), ".") + + defer func() { + if success { + return + } + // add probed field paths as context to error message. + message = fmt.Sprintf(`"%v" == "%v": %s`, fe.FieldA, fe.FieldB, message) + }() + + fieldAVal, ok, err := unstructured.NestedFieldCopy(obj.Object, fieldAPath...) + if err != nil || !ok { + return false, fmt.Sprintf(`"%v" missing`, fe.FieldA) + } + fieldBVal, ok, err := unstructured.NestedFieldCopy(obj.Object, fieldBPath...) + if err != nil || !ok { + return false, fmt.Sprintf(`"%v" missing`, fe.FieldB) + } + + if !equality.Semantic.DeepEqual(fieldAVal, fieldBVal) { + return false, fmt.Sprintf(`"%v" != "%v"`, fieldAVal, fieldBVal) + } + return true, "" +} + +// statusObservedGenerationProbe wraps the given Prober and ensures that .status.observedGeneration is equal to .metadata.generation, +// before running the given probe. If the probed object does not contain the .status.observedGeneration field, +// the given prober is executed directly. +type statusObservedGenerationProbe struct { + Prober +} + +var _ Prober = (*statusObservedGenerationProbe)(nil) + +func (cg *statusObservedGenerationProbe) Probe(obj *unstructured.Unstructured) (success bool, message string) { + if observedGeneration, ok, err := unstructured.NestedInt64( + obj.Object, "status", "observedGeneration", + ); err == nil && ok && observedGeneration != obj.GetGeneration() { + return false, ".status outdated" + } + return cg.Prober.Probe(obj) +} diff --git a/internal/probing/probe_test.go b/internal/probing/probe_test.go new file mode 100644 index 00000000..cd1cb8ef --- /dev/null +++ b/internal/probing/probe_test.go @@ -0,0 +1,429 @@ +package probing + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var _ Prober = (*proberMock)(nil) + +type proberMock struct { + mock.Mock +} + +func (m *proberMock) Probe(obj *unstructured.Unstructured) ( + success bool, message string, +) { + args := m.Called(obj) + return args.Bool(0), args.String(1) +} + +func TestList(t *testing.T) { + t.Parallel() + prober1 := &proberMock{} + prober2 := &proberMock{} + + prober1. + On("Probe", mock.Anything). + Return(false, "error from prober1") + prober2. + On("Probe", mock.Anything). + Return(false, "error from prober2") + + l := list{prober1, prober2} + + s, m := l.Probe(&unstructured.Unstructured{}) + assert.False(t, s) + assert.Equal(t, "error from prober1, error from prober2", m) +} + +func TestCondition(t *testing.T) { + t.Parallel() + c := &conditionProbe{ + Type: "Available", + Status: "False", + } + + tests := []struct { + name string + obj *unstructured.Unstructured + succeeds bool + message string + }{ + { + name: "succeeds", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Banana", + "status": "True", + "observedGeneration": int64(1), // up to date + }, + map[string]interface{}{ + "type": "Available", + "status": "False", + "observedGeneration": int64(1), // up to date + }, + }, + }, + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: true, + message: "", + }, + { + name: "outdated", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Available", + "status": "False", + "observedGeneration": int64(1), // up to date + }, + }, + }, + "metadata": map[string]interface{}{ + "generation": int64(42), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": outdated`, + }, + { + name: "wrong status", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Available", + "status": "Unknown", + "observedGeneration": int64(1), // up to date + }, + }, + }, + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": wrong status`, + }, + { + name: "not reported", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "type": "Banana", + "status": "True", + "observedGeneration": int64(1), // up to date + }, + }, + }, + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": not reported`, + }, + { + name: "malformed condition type int", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": []interface{}{ + 42, 56, + }, + }, + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": malformed`, + }, + { + name: "malformed condition type string", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": []interface{}{ + "42", "56", + }, + }, + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": malformed`, + }, + { + name: "malformed conditions array", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{ + "conditions": 42, + }, + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": malformed`, + }, + { + name: "missing conditions", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "status": map[string]interface{}{}, + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": missing .status.conditions`, + }, + { + name: "missing status", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "generation": int64(1), + }, + }, + }, + succeeds: false, + message: `condition "Available" == "False": missing .status.conditions`, + }, + } + + for i := range tests { + test := tests[i] + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + s, m := c.Probe(test.obj) + assert.Equal(t, test.succeeds, s) + assert.Equal(t, test.message, m) + }) + } +} + +func TestFieldsEqual(t *testing.T) { + t.Parallel() + fe := &fieldsEqualProbe{ + FieldA: ".spec.fieldA", + FieldB: ".spec.fieldB", + } + + tests := []struct { + name string + obj *unstructured.Unstructured + succeeds bool + message string + }{ + { + name: "simple succeeds", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "fieldA": "test", + "fieldB": "test", + }, + }, + }, + succeeds: true, + }, + { + name: "simple not equal", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "fieldA": "test", + "fieldB": "not test", + }, + }, + }, + succeeds: false, + message: `".spec.fieldA" == ".spec.fieldB": "test" != "not test"`, + }, + { + name: "complex succeeds", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "fieldA": map[string]interface{}{ + "fk": "fv", + }, + "fieldB": map[string]interface{}{ + "fk": "fv", + }, + }, + }, + }, + succeeds: true, + }, + { + name: "simple not equal", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "fieldA": map[string]interface{}{ + "fk": "fv", + }, + "fieldB": map[string]interface{}{ + "fk": "something else", + }, + }, + }, + }, + succeeds: false, + message: `".spec.fieldA" == ".spec.fieldB": "map[fk:fv]" != "map[fk:something else]"`, + }, + { + name: "int not equal", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "fieldA": map[string]interface{}{ + "fk": 1.0, + }, + "fieldB": map[string]interface{}{ + "fk": 2.0, + }, + }, + }, + }, + succeeds: false, + message: `".spec.fieldA" == ".spec.fieldB": "map[fk:1]" != "map[fk:2]"`, + }, + { + name: "fieldA missing", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "fieldB": "test", + }, + }, + }, + succeeds: false, + message: `".spec.fieldA" == ".spec.fieldB": ".spec.fieldA" missing`, + }, + { + name: "fieldB missing", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "spec": map[string]interface{}{ + "fieldA": "test", + }, + }, + }, + succeeds: false, + message: `".spec.fieldA" == ".spec.fieldB": ".spec.fieldB" missing`, + }, + } + + for i := range tests { + test := tests[i] + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + s, m := fe.Probe(test.obj) + assert.Equal(t, test.succeeds, s) + assert.Equal(t, test.message, m) + }) + } +} + +func TestStatusObservedGeneration(t *testing.T) { + t.Parallel() + properMock := &proberMock{} + og := &statusObservedGenerationProbe{ + Prober: properMock, + } + + properMock.On("Probe", mock.Anything).Return(true, "banana") + + tests := []struct { + name string + obj *unstructured.Unstructured + succeeds bool + message string + }{ + { + name: "outdated", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "generation": int64(4), + }, + "status": map[string]interface{}{ + "observedGeneration": int64(2), + }, + }, + }, + succeeds: false, + message: ".status outdated", + }, + { + name: "up-to-date", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "generation": int64(4), + }, + "status": map[string]interface{}{ + "observedGeneration": int64(4), + }, + }, + }, + succeeds: true, + message: "banana", + }, + { + name: "not reported", + obj: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "metadata": map[string]interface{}{ + "generation": int64(4), + }, + "status": map[string]interface{}{}, + }, + }, + succeeds: true, + message: "banana", + }, + } + + for i := range tests { + test := tests[i] + + t.Run(test.name, func(t *testing.T) { + t.Parallel() + s, m := og.Probe(test.obj) + assert.Equal(t, test.succeeds, s) + assert.Equal(t, test.message, m) + }) + } +} diff --git a/internal/probing/selectors.go b/internal/probing/selectors.go new file mode 100644 index 00000000..f0b8a9a5 --- /dev/null +++ b/internal/probing/selectors.go @@ -0,0 +1,40 @@ +package probing + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// kindSelector wraps a Probe object and only executes the probe when the probed object is of the right Group and Kind. +type kindSelector struct { + Prober + schema.GroupKind +} + +func (kp *kindSelector) Probe(obj *unstructured.Unstructured) (success bool, message string) { + gvk := obj.GetObjectKind().GroupVersionKind() + if kp.Kind == gvk.Kind && + kp.Group == gvk.Group { + return kp.Prober.Probe(obj) + } + + // We want to _skip_ objects, that don't match. + // So this probe succeeds by default. + return true, "" +} + +type selectorSelector struct { + Prober + labels.Selector +} + +func (ss *selectorSelector) Probe(obj *unstructured.Unstructured) (success bool, message string) { + if !ss.Selector.Matches(labels.Set(obj.GetLabels())) { + // We want to _skip_ objects, that don't match. + // So this probe succeeds by default. + return true, "" + } + + return ss.Prober.Probe(obj) +}