diff --git a/.gitignore b/.gitignore index ed0fe68..5509cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ bin/* *.test # Output of the go coverage tool, specifically when used with LiteIDE -cover.html +cover*.html *.out # Go workspace file diff --git a/README.md b/README.md index 58c22d5..6a2f5a9 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,50 @@ The `pkg/collections` package contains multiple interfaces for collections, mode The package also contains further packages that contain some auxiliary functions for working with slices and maps in golang, e.g. for filtering. +### conditions + +The `pkg/conditions` package helps with managing condition lists. + +The managed condition implementation must satisfy the `Condition[T comparable]` interface: +```go +type Condition[T comparable] interface { + GetType() string + SetType(conType string) + GetStatus() T + SetStatus(status T) + GetLastTransitionTime() time.Time + SetLastTransitionTime(timestamp time.Time) + GetReason() string + SetReason(reason string) + GetMessage() string + SetMessage(message string) +} +``` + +To manage conditions, use the `ConditionUpdater` function and pass in a constructor function for your condition implementation and the old list of conditions. The bool argument determines whether old conditions that are not updated remain in the returned list (`false`) or are removed, so that the returned list contains only the conditions that were touched (`true`). + +```go +updater := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, oldCons, false) +``` + +Note that the `ConditionUpdater` stores the current time upon initialization and will set each updated condition's timestamp to this value, if the status of that condition changed as a result of the update. To use a different timestamp, manually overwrite the `Now` field of the updater. + +Use `UpdateCondition` or `UpdateConditionFromTemplate` to update a condition: +```go +updater.UpdateCondition("myCondition", true, "newReason", "newMessage") +``` + +If all conditions are updated, use the `Conditions` method to generate the new list of conditions. The originally passed in list of conditions is not modified by the updater. +The second return value is `true` if the updated list of conditions differs from the original one. +```go +updatedCons, changed := updater.Conditions() +``` + +For simplicity, all commands can be chained: +```go +updatedCons, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, oldCons, false).UpdateCondition("myCondition", true, "newReason", "newMessage").Conditions() +``` + ### controller The `pkg/controller` package contains useful functions for setting up and running k8s controllers. @@ -51,7 +95,6 @@ The `pkg/controller` package contains useful functions for setting up and runnin ### logging - This package contains the logging library from the [Landscaper controller-utils module](https://github.com/gardener/landscaper/tree/master/controller-utils/pkg/logging). The library provides a wrapper around `logr.Logger`, exposing additional helper functions. The original `logr.Logger` can be retrieved by using the `Logr()` method. Also, it notices when multiple values are added to the logger with the same key - instead of simply overwriting the previous ones (like `logr.Logger` does it), it appends the key with a `_conflict(x)` suffix, where `x` is the number of times this conflict has occurred. diff --git a/pkg/conditions/conditions.go b/pkg/conditions/conditions.go new file mode 100644 index 0000000..9a5c0ca --- /dev/null +++ b/pkg/conditions/conditions.go @@ -0,0 +1,52 @@ +package conditions + +import "time" + +// Condition represents a condition consisting of type, status, reason, message and a last transition timestamp. +type Condition[T comparable] interface { + // SetStatus sets the status of the condition. + SetStatus(status T) + // GetStatus returns the status of the condition. + GetStatus() T + + // SetType sets the type of the condition. + SetType(conType string) + // GetType returns the type of the condition. + GetType() string + + // SetLastTransitionTime sets the timestamp of the condition. + SetLastTransitionTime(timestamp time.Time) + // GetLastTransitionTime returns the timestamp of the condition. + GetLastTransitionTime() time.Time + + // SetReason sets the reason of the condition. + SetReason(reason string) + // GetReason returns the reason of the condition. + GetReason() string + + // SetMessage sets the message of the condition. + SetMessage(message string) + // GetMessage returns the message of the condition. + GetMessage() string +} + +// GetCondition returns a pointer to the condition for the given type, if it exists. +// Otherwise, nil is returned. +func GetCondition[T comparable](ccl []Condition[T], t string) Condition[T] { + for i := range ccl { + if ccl[i].GetType() == t { + return ccl[i] + } + } + return nil +} + +// AllConditionsHaveStatus returns true if all conditions have the specified status. +func AllConditionsHaveStatus[T comparable](status T, conditions ...Condition[T]) bool { + for _, con := range conditions { + if con.GetStatus() != status { + return false + } + } + return true +} diff --git a/pkg/conditions/suite_test.go b/pkg/conditions/suite_test.go new file mode 100644 index 0000000..ca46a86 --- /dev/null +++ b/pkg/conditions/suite_test.go @@ -0,0 +1,14 @@ +package conditions_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConditions(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Conditions Test Suite") +} diff --git a/pkg/conditions/updater.go b/pkg/conditions/updater.go new file mode 100644 index 0000000..9a32a02 --- /dev/null +++ b/pkg/conditions/updater.go @@ -0,0 +1,123 @@ +package conditions + +import ( + "slices" + "strings" + "time" + + "k8s.io/apimachinery/pkg/util/sets" +) + +// conditionUpdater is a helper struct for updating a list of Conditions. +// Use the ConditionUpdater constructor for initializing. +type conditionUpdater[T comparable] struct { + Now time.Time + conditions map[string]Condition[T] + updated sets.Set[string] + constructor func() Condition[T] + changed bool +} + +// ConditionUpdater creates a builder-like helper struct for updating a list of Conditions. +// The 'constructor' argument is a function that returns a new (empty) instance of the condition implementation type. +// The 'conditions' argument contains the old condition list. +// If removeUntouched is true, the condition list returned with Conditions() will have all conditions removed that have not been updated. +// If false, all conditions will be kept. +// Note that calling this function stores the current time as timestamp that is used as timestamp if a condition's status changed. +// To overwrite this timestamp, modify the 'Now' field of the returned struct manually. +// +// The given condition list is not modified. +// +// Usage example: +// status.conditions = ConditionUpdater(status.conditions, true).UpdateCondition(...).UpdateCondition(...).Conditions() +func ConditionUpdater[T comparable](constructor func() Condition[T], conditions []Condition[T], removeUntouched bool) *conditionUpdater[T] { + res := &conditionUpdater[T]{ + Now: time.Now(), + conditions: make(map[string]Condition[T], len(conditions)), + constructor: constructor, + changed: false, + } + for _, con := range conditions { + res.conditions[con.GetType()] = con + } + if removeUntouched { + res.updated = sets.New[string]() + } + return res +} + +// UpdateCondition updates or creates the condition with the specified type. +// All fields of the condition are updated with the values given in the arguments, but the condition's LastTransitionTime is only updated (with the timestamp contained in the receiver struct) if the status changed. +// Returns the receiver for easy chaining. +func (c *conditionUpdater[T]) UpdateCondition(conType string, status T, reason, message string) *conditionUpdater[T] { + con := c.constructor() + con.SetType(conType) + con.SetStatus(status) + con.SetReason(reason) + con.SetMessage(message) + con.SetLastTransitionTime(c.Now) + old, ok := c.conditions[conType] + if ok && old.GetStatus() == con.GetStatus() { + // update LastTransitionTime only if status changed + con.SetLastTransitionTime(old.GetLastTransitionTime()) + } + if !c.changed { + if ok { + c.changed = old.GetStatus() != con.GetStatus() || old.GetReason() != con.GetReason() || old.GetMessage() != con.GetMessage() + } else { + c.changed = true + } + } + c.conditions[conType] = con + if c.updated != nil { + c.updated.Insert(conType) + } + return c +} + +// UpdateConditionFromTemplate is a convenience wrapper around UpdateCondition which allows it to be called with a preconstructed ComponentCondition. +func (c *conditionUpdater[T]) UpdateConditionFromTemplate(con Condition[T]) *conditionUpdater[T] { + return c.UpdateCondition(con.GetType(), con.GetStatus(), con.GetReason(), con.GetMessage()) +} + +// HasCondition returns true if a condition with the given type exists in the updated condition list. +func (c *conditionUpdater[T]) HasCondition(conType string) bool { + _, ok := c.conditions[conType] + return ok && (c.updated == nil || c.updated.Has(conType)) +} + +// RemoveCondition removes the condition with the given type from the updated condition list. +func (c *conditionUpdater[T]) RemoveCondition(conType string) *conditionUpdater[T] { + if !c.HasCondition(conType) { + return c + } + delete(c.conditions, conType) + if c.updated != nil { + c.updated.Delete(conType) + } + c.changed = true + return c +} + +// Conditions returns the updated condition list. +// If the condition updater was initialized with removeUntouched=true, this list will only contain the conditions which have been updated +// in between the condition updater creation and this method call. Otherwise, it will potentially also contain old conditions. +// The conditions are returned sorted by their type. +func (c *conditionUpdater[T]) Conditions() ([]Condition[T], bool) { + res := make([]Condition[T], 0, len(c.conditions)) + for _, con := range c.conditions { + if c.updated == nil { + res = append(res, con) + continue + } + if c.updated.Has(con.GetType()) { + res = append(res, con) + } else { + c.changed = true + } + } + slices.SortStableFunc(res, func(a, b Condition[T]) int { + return strings.Compare(a.GetType(), b.GetType()) + }) + return res, c.changed +} diff --git a/pkg/conditions/updater_test.go b/pkg/conditions/updater_test.go new file mode 100644 index 0000000..b3e033f --- /dev/null +++ b/pkg/conditions/updater_test.go @@ -0,0 +1,258 @@ +package conditions_test + +import ( + "slices" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/openmcp-project/controller-utils/pkg/conditions" +) + +type conImpl struct { + status bool + conType string + reason string + message string + lastTransitionTime time.Time +} + +func newConImplWithValues(conType string, status bool, reason, message string, lastTransitionTime time.Time) *conImpl { + return &conImpl{ + conType: conType, + status: status, + reason: reason, + message: message, + lastTransitionTime: lastTransitionTime, + } +} + +var _ conditions.Condition[bool] = &conImpl{} + +func (c *conImpl) GetLastTransitionTime() time.Time { + return c.lastTransitionTime +} + +func (c *conImpl) GetType() string { + return c.conType +} + +func (c *conImpl) GetStatus() bool { + return c.status +} + +func (c *conImpl) GetReason() string { + return c.reason +} + +func (c *conImpl) GetMessage() string { + return c.message +} + +func (c *conImpl) SetStatus(status bool) { + c.status = status +} + +func (c *conImpl) SetType(conType string) { + c.conType = conType +} + +func (c *conImpl) SetLastTransitionTime(timestamp time.Time) { + c.lastTransitionTime = timestamp +} + +func (c *conImpl) SetReason(reason string) { + c.reason = reason +} + +func (c *conImpl) SetMessage(message string) { + c.message = message +} + +func testConditionSet() []conditions.Condition[bool] { + now := time.Now().Add((-24) * time.Hour) + return []conditions.Condition[bool]{ + newConImplWithValues("true", true, "reason", "message", now), + newConImplWithValues("false", false, "reason", "message", now), + newConImplWithValues("alsoTrue", true, "alsoReason", "alsoMessage", now), + } +} + +var _ = Describe("Conditions", func() { + + Context("GetCondition", func() { + + It("should return the requested condition", func() { + cons := testConditionSet() + + con := conditions.GetCondition(cons, "true") + Expect(con).ToNot(BeNil()) + Expect(con.GetType()).To(Equal("true")) + Expect(con.GetStatus()).To(BeTrue()) + + con = conditions.GetCondition(cons, "false") + Expect(con).ToNot(BeNil()) + Expect(con.GetType()).To(Equal("false")) + Expect(con.GetStatus()).To(BeFalse()) + + con = conditions.GetCondition(cons, "alsoTrue") + Expect(con).ToNot(BeNil()) + Expect(con.GetType()).To(Equal("alsoTrue")) + Expect(con.GetStatus()).To(BeTrue()) + }) + + It("should return nil if the condition does not exist", func() { + cons := testConditionSet() + + con := conditions.GetCondition(cons, "doesNotExist") + Expect(con).To(BeNil()) + }) + + It("should return a pointer to the condition, so that it can be changed", func() { + // This depends on the actual implementation of the Condition interface. + // However, most implementations will probably look very similar to the one in this test. + cons := testConditionSet() + + con := conditions.GetCondition(cons, "true") + Expect(con).ToNot(BeNil()) + Expect(con.GetType()).To(Equal("true")) + Expect(con.GetStatus()).To(BeTrue()) + + con.SetStatus(false) + con = conditions.GetCondition(cons, "true") + Expect(con).ToNot(BeNil()) + Expect(con.GetType()).To(Equal("true")) + Expect(con.GetStatus()).To(BeFalse()) + }) + + }) + + Context("ConditionUpdater", func() { + + It("should update the condition (same value, keep other cons)", func() { + cons := testConditionSet() + oldCon := conditions.GetCondition(cons, "true") + updated, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, false).UpdateCondition(oldCon.GetType(), oldCon.GetStatus(), "newReason", "newMessage").Conditions() + Expect(changed).To(BeTrue()) + newCon := conditions.GetCondition(updated, "true") + Expect(updated).To(HaveLen(len(cons))) + Expect(newCon).ToNot(Equal(oldCon)) + Expect(newCon.GetType()).To(Equal(oldCon.GetType())) + Expect(newCon.GetStatus()).To(Equal(oldCon.GetStatus())) + Expect(newCon.GetReason()).To(Equal("newReason")) + Expect(newCon.GetMessage()).To(Equal("newMessage")) + Expect(oldCon.GetReason()).To(Equal("reason")) + Expect(oldCon.GetMessage()).To(Equal("message")) + Expect(newCon.GetLastTransitionTime()).To(Equal(oldCon.GetLastTransitionTime())) + }) + + It("should update the condition (different value, keep other cons)", func() { + cons := testConditionSet() + oldCon := conditions.GetCondition(cons, "true") + updated, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, false).UpdateCondition(oldCon.GetType(), !oldCon.GetStatus(), "newReason", "newMessage").Conditions() + Expect(changed).To(BeTrue()) + newCon := conditions.GetCondition(updated, "true") + Expect(updated).To(HaveLen(len(cons))) + Expect(newCon).ToNot(Equal(oldCon)) + Expect(newCon.GetType()).To(Equal(oldCon.GetType())) + Expect(newCon.GetStatus()).To(Equal(!oldCon.GetStatus())) + Expect(newCon.GetReason()).To(Equal("newReason")) + Expect(newCon.GetMessage()).To(Equal("newMessage")) + Expect(oldCon.GetReason()).To(Equal("reason")) + Expect(oldCon.GetMessage()).To(Equal("message")) + Expect(newCon.GetLastTransitionTime()).ToNot(Equal(oldCon.GetLastTransitionTime())) + Expect(newCon.GetLastTransitionTime().After(oldCon.GetLastTransitionTime())).To(BeTrue()) + }) + + It("should update the condition (same value, discard other cons)", func() { + cons := testConditionSet() + oldCon := conditions.GetCondition(cons, "true") + updated, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, true).UpdateCondition(oldCon.GetType(), oldCon.GetStatus(), "newReason", "newMessage").Conditions() + Expect(changed).To(BeTrue()) + newCon := conditions.GetCondition(updated, "true") + Expect(updated).To(HaveLen(1)) + Expect(newCon).ToNot(Equal(oldCon)) + Expect(newCon.GetType()).To(Equal(oldCon.GetType())) + Expect(newCon.GetStatus()).To(Equal(oldCon.GetStatus())) + Expect(newCon.GetReason()).To(Equal("newReason")) + Expect(newCon.GetMessage()).To(Equal("newMessage")) + Expect(oldCon.GetReason()).To(Equal("reason")) + Expect(oldCon.GetMessage()).To(Equal("message")) + Expect(newCon.GetLastTransitionTime()).To(Equal(oldCon.GetLastTransitionTime())) + }) + + It("should update the condition (different value, discard other cons)", func() { + cons := testConditionSet() + oldCon := conditions.GetCondition(cons, "true") + updated, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, true).UpdateCondition(oldCon.GetType(), !oldCon.GetStatus(), "newReason", "newMessage").Conditions() + Expect(changed).To(BeTrue()) + newCon := conditions.GetCondition(updated, "true") + Expect(updated).To(HaveLen(1)) + Expect(newCon).ToNot(Equal(oldCon)) + Expect(newCon.GetType()).To(Equal(oldCon.GetType())) + Expect(newCon.GetStatus()).To(Equal(!oldCon.GetStatus())) + Expect(newCon.GetReason()).To(Equal("newReason")) + Expect(newCon.GetMessage()).To(Equal("newMessage")) + Expect(oldCon.GetReason()).To(Equal("reason")) + Expect(oldCon.GetMessage()).To(Equal("message")) + Expect(newCon.GetLastTransitionTime()).ToNot(Equal(oldCon.GetLastTransitionTime())) + Expect(newCon.GetLastTransitionTime().After(oldCon.GetLastTransitionTime())).To(BeTrue()) + }) + + It("should sort the conditions by type", func() { + cons := []conditions.Condition[bool]{ + newConImplWithValues("c", true, "reason", "message", time.Now()), + newConImplWithValues("d", true, "reason", "message", time.Now()), + newConImplWithValues("a", true, "reason", "message", time.Now()), + newConImplWithValues("b", true, "reason", "message", time.Now()), + } + compareConditions := func(a, b conditions.Condition[bool]) int { + return strings.Compare(a.GetType(), b.GetType()) + } + Expect(slices.IsSortedFunc(cons, compareConditions)).To(BeFalse(), "conditions in the test object are already sorted, unable to test sorting") + updated, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, false).Conditions() + Expect(changed).To(BeFalse()) + Expect(len(updated)).To(BeNumerically(">", 1), "test object does not contain enough conditions to test sorting") + Expect(len(updated)).To(Equal(len(cons))) + Expect(slices.IsSortedFunc(updated, compareConditions)).To(BeTrue(), "conditions are not sorted") + }) + + It("should remove a condition", func() { + cons := testConditionSet() + updated, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, false).RemoveCondition("true").Conditions() + Expect(changed).To(BeTrue()) + Expect(updated).To(HaveLen(len(cons) - 1)) + con := conditions.GetCondition(updated, "true") + Expect(con).To(BeNil()) + + // removing a condition that does not exist should not change anything + updated, changed = conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, false).RemoveCondition("doesNotExist").Conditions() + Expect(changed).To(BeFalse()) + Expect(updated).To(HaveLen(len(cons))) + }) + + It("should not mark a condition as changed if it has the same values as before", func() { + cons := testConditionSet() + con := conditions.GetCondition(cons, "true") + updated, changed := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, false).UpdateCondition(con.GetType(), con.GetStatus(), con.GetReason(), con.GetMessage()).Conditions() + Expect(changed).To(BeFalse()) + Expect(updated).To(HaveLen(len(cons))) + }) + + It("should return that a condition exists only if it will be contained in the returned list", func() { + cons := testConditionSet() + updater := conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, false) + Expect(updater.HasCondition("true")).To(BeTrue()) + Expect(updater.HasCondition("doesNotExist")).To(BeFalse()) + updater = conditions.ConditionUpdater(func() conditions.Condition[bool] { return &conImpl{} }, cons, true) + Expect(updater.HasCondition("true")).To(BeFalse()) + Expect(updater.HasCondition("doesNotExist")).To(BeFalse()) + updater.UpdateCondition("true", true, "reason", "message") + Expect(updater.HasCondition("true")).To(BeTrue()) + }) + + }) + +})