Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 64 additions & 6 deletions pkg/testing/complex_environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ import (
"time"

"github.com/onsi/gomega"
"github.com/onsi/gomega/types"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/uuid"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/client/fake"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

"github.com/openmcp-project/controller-utils/pkg/logging"
"github.com/openmcp-project/controller-utils/pkg/testing/matchers"
)

/////////////////
Expand All @@ -31,6 +35,8 @@ func DefaultScheme() *runtime.Scheme {
return sc
}

var noMatcher types.GomegaMatcher = nil

///////////////////////////
/// COMPLEX ENVIRONMENT ///
///////////////////////////
Expand Down Expand Up @@ -82,27 +88,37 @@ func (e *ComplexEnvironment) shouldEventuallyReconcile(reconciler string, req re

// ShouldNotReconcile calls the given reconciler with the given request and expects an error.
func (e *ComplexEnvironment) ShouldNotReconcile(reconciler string, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result {
return e.shouldNotReconcile(reconciler, req, optionalDescription...)
return e.shouldNotReconcile(reconciler, req, noMatcher, optionalDescription...)
}

// ShouldNotReconcileWithError calls the given reconciler with the given request and expects an error that matches the given matcher.
func (e *ComplexEnvironment) ShouldNotReconcileWithError(reconciler string, req reconcile.Request, matcher types.GomegaMatcher, optionalDescription ...interface{}) reconcile.Result {
return e.shouldNotReconcile(reconciler, req, matcher, optionalDescription...)
}

func (e *ComplexEnvironment) shouldNotReconcile(reconciler string, req reconcile.Request, optionalDescription ...interface{}) reconcile.Result {
func (e *ComplexEnvironment) shouldNotReconcile(reconciler string, req reconcile.Request, matcher types.GomegaMatcher, optionalDescription ...interface{}) reconcile.Result {
res, err := e.Reconcilers[reconciler].Reconcile(e.Ctx, req)
gomega.ExpectWithOffset(2, err).To(gomega.HaveOccurred(), optionalDescription...)
gomega.ExpectWithOffset(2, err).To(gomega.And(gomega.HaveOccurred(), matchers.MaybeMatch(matcher)), optionalDescription...)
return res
}

// ShouldEventuallyNotReconcile calls the given reconciler with the given request and retries until an error occurred or the timeout is reached.
func (e *ComplexEnvironment) ShouldEventuallyNotReconcile(reconciler string, req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result {
return e.shouldEventuallyNotReconcile(reconciler, req, timeout, poll, optionalDescription...)
return e.shouldEventuallyNotReconcile(reconciler, req, noMatcher, timeout, poll, optionalDescription...)
}

// ShouldEventuallyNotReconcileWithError calls the given reconciler with the given request and retries until an error that matches the given matcher occurred or the timeout is reached.
func (e *ComplexEnvironment) ShouldEventuallyNotReconcileWithError(reconciler string, req reconcile.Request, matcher types.GomegaMatcher, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result {
return e.shouldEventuallyNotReconcile(reconciler, req, matcher, timeout, poll, optionalDescription...)
}

func (e *ComplexEnvironment) shouldEventuallyNotReconcile(reconciler string, req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result {
func (e *ComplexEnvironment) shouldEventuallyNotReconcile(reconciler string, req reconcile.Request, matcher types.GomegaMatcher, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result {
var err error
var res reconcile.Result
gomega.EventuallyWithOffset(1, func() error {
res, err = e.Reconcilers[reconciler].Reconcile(e.Ctx, req)
return err
}, timeout, poll).ShouldNot(gomega.Succeed(), optionalDescription...)
}, timeout, poll).ShouldNot(gomega.And(gomega.Succeed(), matchers.MaybeMatch(matcher)), optionalDescription...)
return res
}

Expand All @@ -121,6 +137,7 @@ type ComplexEnvironmentBuilder struct {
ClusterInitObjectPaths map[string][]string
ClientCreationCallbacks map[string][]func(client.Client)
loggerIsSet bool
InjectUIDs map[string]bool
}

type ClusterEnvironment struct {
Expand Down Expand Up @@ -163,6 +180,7 @@ func NewComplexEnvironmentBuilder() *ComplexEnvironmentBuilder {
ClusterStatusObjects: map[string][]client.Object{},
ClusterInitObjectPaths: map[string][]string{},
ClientCreationCallbacks: map[string][]func(client.Client){},
InjectUIDs: map[string]bool{},
}
}

Expand Down Expand Up @@ -264,6 +282,16 @@ func (eb *ComplexEnvironmentBuilder) WithAfterClientCreationCallback(name string
return eb
}

// WithUIDs enables UID injection for the specified cluster.
// All objects that are initially loaded or afterwards created via the client's 'Create' method will have a random UID injected, if they do not already have one.
// Note that this function registers an interceptor function, which will be overwritten if 'WithFakeClientBuilderCall(..., "WithInterceptorFuncs", ...)' is also called.
// This would lead to newly created objects not having a UID injected.
// To avoid this, pass 'InjectUIDOnObjectCreation(...)' into the interceptor.Funcs' Create field. The argument allows to inject your own additional Create logic, if desired.
func (eb *ComplexEnvironmentBuilder) WithUIDs(name string) *ComplexEnvironmentBuilder {
eb.InjectUIDs[name] = true
return eb
}

// WithFakeClientBuilderCall allows to inject method calls to fake.ClientBuilder when the fake clients are created during Build().
// The fake clients are usually created using WithScheme(...).WithObjects(...).WithStatusSubresource(...).Build().
// This function allows to inject additional method calls. It is only required for advanced use-cases.
Expand All @@ -284,6 +312,8 @@ func (eb *ComplexEnvironmentBuilder) WithFakeClientBuilderCall(name string, meth
// Build constructs the environment from the builder.
// Note that this function panics instead of throwing an error,
// as it is intended to be used in tests, where all information is static anyway.
//
//nolint:gocyclo
func (eb *ComplexEnvironmentBuilder) Build() *ComplexEnvironment {
res := eb.internal

Expand Down Expand Up @@ -335,6 +365,18 @@ func (eb *ComplexEnvironmentBuilder) Build() *ComplexEnvironment {
if len(eb.ClusterInitObjects) > 0 {
objs = append(objs, eb.ClusterInitObjects[name]...)
}
if eb.InjectUIDs[name] {
// ensure that objects have a uid
for _, obj := range objs {
if obj.GetUID() == "" {
// set a random UID if not already set
obj.SetUID(uuid.NewUUID())
}
}
fcb.WithInterceptorFuncs(interceptor.Funcs{
Create: InjectUIDOnObjectCreation(nil),
})
}
statusObjs := []client.Object{}
statusObjs = append(statusObjs, objs...)
statusObjs = append(statusObjs, eb.ClusterStatusObjects[name]...)
Expand Down Expand Up @@ -396,3 +438,19 @@ func (eb *ComplexEnvironmentBuilder) Build() *ComplexEnvironment {

return res
}

// InjectUIDOnObjectCreation returns an interceptor function for Create which injects a random UID into the object, if it does not already have one.
// If additionalLogic is nil, the object is created regularly afterwards.
// Otherwise, additionalLogic is called.
// If you called 'WithUIDs(...)' on the ComplexEnvironmentBuilder AND 'WithFakeClientBuilderCall(..., "WithInterceptorFuncs", ...)', then you need to pass this function into the interceptor.Funcs' Create field, optionally adding your own creation logic via additionalLogic.
func InjectUIDOnObjectCreation(additionalLogic func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error) func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
return func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error {
if obj.GetUID() == "" {
obj.SetUID(uuid.NewUUID())
}
if additionalLogic != nil {
return additionalLogic(ctx, client, obj, opts...)
}
return client.Create(ctx, obj, opts...)
}
}
26 changes: 24 additions & 2 deletions pkg/testing/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

"github.com/onsi/gomega/types"

"github.com/openmcp-project/controller-utils/pkg/logging"
)

Expand Down Expand Up @@ -48,12 +50,22 @@ func (e *Environment) ShouldEventuallyReconcile(req reconcile.Request, timeout,

// ShouldNotReconcile calls the given reconciler with the given request and expects an error.
func (e *Environment) ShouldNotReconcile(req reconcile.Request, optionalDescription ...interface{}) reconcile.Result {
return e.shouldNotReconcile(SimpleEnvironmentDefaultKey, req, optionalDescription...)
return e.shouldNotReconcile(SimpleEnvironmentDefaultKey, req, nil, optionalDescription...)
}

// ShouldEventuallyNotReconcile calls the given reconciler with the given request and retries until an error occurred or the timeout is reached.
func (e *Environment) ShouldEventuallyNotReconcile(req reconcile.Request, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result {
return e.shouldEventuallyNotReconcile(SimpleEnvironmentDefaultKey, req, timeout, poll, optionalDescription...)
return e.shouldEventuallyNotReconcile(SimpleEnvironmentDefaultKey, req, nil, timeout, poll, optionalDescription...)
}

// ShouldNotReconcileWithError calls the given reconciler with the given request and expects an error that matches the given matcher.
func (e *Environment) ShouldNotReconcileWithError(req reconcile.Request, matcher types.GomegaMatcher, optionalDescription ...interface{}) reconcile.Result {
return e.shouldNotReconcile(SimpleEnvironmentDefaultKey, req, matcher, optionalDescription...)
}

// ShouldEventuallyNotReconcileWithError calls the given reconciler with the given request and retries until an error that matches the given matcher occurred or the timeout is reached.
func (e *Environment) ShouldEventuallyNotReconcileWithError(req reconcile.Request, matcher types.GomegaMatcher, timeout, poll time.Duration, optionalDescription ...interface{}) reconcile.Result {
return e.shouldEventuallyNotReconcile(SimpleEnvironmentDefaultKey, req, matcher, timeout, poll, optionalDescription...)
}

//////////////////////////////////
Expand Down Expand Up @@ -153,6 +165,16 @@ func (eb *EnvironmentBuilder) WithAfterClientCreationCallback(callback func(clie
return eb
}

// WithUIDs enables UID injection.
// All objects that are initially loaded or afterwards created via the client's 'Create' method will have a random UID injected, if they do not already have one.
// Note that this function registers an interceptor function, which will be overwritten if 'WithFakeClientBuilderCall("WithInterceptorFuncs", ...)' is also called.
// This would lead to newly created objects not having a UID injected.
// To avoid this, pass 'InjectUIDOnObjectCreation(...)' into the interceptor.Funcs' Create field. The argument allows to inject your own additional Create logic, if desired.
func (eb *EnvironmentBuilder) WithUIDs() *EnvironmentBuilder {
eb.ComplexEnvironmentBuilder.WithUIDs(SimpleEnvironmentDefaultKey)
return eb
}

// WithFakeClientBuilderCall allows to inject method calls to fake.ClientBuilder when the fake client is created during Build().
// The fake client is usually created using WithScheme(...).WithObjects(...).WithStatusSubresource(...).Build().
// This function allows to inject additional method calls. It is only required for advanced use-cases.
Expand Down
44 changes: 44 additions & 0 deletions pkg/testing/matchers/maybematch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package matchers

import (
"fmt"

"github.com/onsi/gomega/types"
)

// MaybeMatch returns a Gomega matcher that passes the matching logic to the provided matcher,
// but always succeeds if the passed in matcher is nil.
func MaybeMatch(matcher types.GomegaMatcher) types.GomegaMatcher {
return &maybeMatcher{matcher: matcher}
}

type maybeMatcher struct {
matcher types.GomegaMatcher
}

func (m *maybeMatcher) GomegaString() string {
if m == nil || m.matcher == nil {
return "<nil>"
}
return fmt.Sprintf("MaybeMatch(%v)", m.matcher)
}

var _ types.GomegaMatcher = &maybeMatcher{}

// Match implements types.GomegaMatcher.
func (m *maybeMatcher) Match(actualRaw any) (success bool, err error) {
if m.matcher == nil {
return true, nil
}
return m.matcher.Match(actualRaw)
}

// FailureMessage implements types.GomegaMatcher.
func (m *maybeMatcher) FailureMessage(actual any) (message string) {
return m.matcher.FailureMessage(actual)
}

// NegatedFailureMessage implements types.GomegaMatcher.
func (m *maybeMatcher) NegatedFailureMessage(actual any) (message string) {
return m.matcher.NegatedFailureMessage(actual)
}