diff --git a/internal/utils/conditions.go b/internal/utils/conditions.go new file mode 100644 index 00000000..d4f4a896 --- /dev/null +++ b/internal/utils/conditions.go @@ -0,0 +1,138 @@ +/* +SPDX-FileCopyrightText: Copyright 2024 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +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 utils + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sacmetav1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// ConditionFromStatus converts a single metav1.Condition into a +// *k8sacmetav1.ConditionApplyConfiguration, preserving all fields verbatim +// (including LastTransitionTime and ObservedGeneration). +// +// Controllers must seed their apply-config payload with the conditions they +// own before mutating them. With +listType=map / +listMapKey=type on the CRD, +// SSA merges conditions by the "type" key: conditions owned by other field +// managers are left untouched, and conditions previously written by this +// field manager that are absent from the payload are pruned. Therefore each +// controller should include all conditions it has previously set — and only +// those — in every Apply call. +func ConditionFromStatus(c metav1.Condition) *k8sacmetav1.ConditionApplyConfiguration { + return &k8sacmetav1.ConditionApplyConfiguration{ + Type: &c.Type, + Status: &c.Status, + Reason: &c.Reason, + Message: &c.Message, + LastTransitionTime: &c.LastTransitionTime, + ObservedGeneration: &c.ObservedGeneration, + } +} + +// SetApplyConfigurationStatusCondition sets the corresponding condition in +// conditions to newCondition and returns true if the conditions are changed by +// this call. It mirrors the behaviour of k8s.io/apimachinery/pkg/api/meta.SetStatusCondition: +// +// 1. If a condition of the specified type does not exist, newCondition is +// appended. LastTransitionTime is set to now if not provided. +// 2. If a condition of the specified type already exists, all fields are +// updated. LastTransitionTime is updated to now (or the provided value) +// only when Status changes; otherwise the existing LastTransitionTime is +// preserved. +func SetApplyConfigurationStatusCondition(conditions *[]k8sacmetav1.ConditionApplyConfiguration, newCondition k8sacmetav1.ConditionApplyConfiguration) (changed bool) { + if conditions == nil { + return false + } + if newCondition.Type == nil || *newCondition.Type == "" { + return false + } + + // Find existing entry by type + var existing *k8sacmetav1.ConditionApplyConfiguration + for i := range *conditions { + if (*conditions)[i].Type != nil && *(*conditions)[i].Type == *newCondition.Type { + existing = &(*conditions)[i] + break + } + } + + if existing == nil { + // New condition: set LastTransitionTime if not provided + if newCondition.LastTransitionTime == nil { + now := metav1.Now() + newCondition.LastTransitionTime = &now + } + *conditions = append(*conditions, newCondition) + return true + } + + // Existing condition: update fields, handle LastTransitionTime + statusChanged := existing.Status == nil || newCondition.Status == nil || + *existing.Status != *newCondition.Status + + if statusChanged { + existing.Status = newCondition.Status + if newCondition.LastTransitionTime != nil { + existing.LastTransitionTime = newCondition.LastTransitionTime + } else { + now := metav1.Now() + existing.LastTransitionTime = &now + } + changed = true + } + // When status is unchanged, LastTransitionTime is intentionally preserved. + + if strChanged(&existing.Reason, newCondition.Reason) { + existing.Reason = newCondition.Reason + changed = true + } + if strChanged(&existing.Message, newCondition.Message) { + existing.Message = newCondition.Message + changed = true + } + if int64Changed(&existing.ObservedGeneration, newCondition.ObservedGeneration) { + existing.ObservedGeneration = newCondition.ObservedGeneration + changed = true + } + + return changed +} + +// strChanged reports whether the pointer value of b differs from the current +// value at a. +func strChanged(a **string, b *string) bool { + if *a == nil && b == nil { + return false + } + if *a == nil || b == nil { + return true + } + return **a != *b +} + +// int64Changed reports whether the pointer value of b differs from the current +// value at a. +func int64Changed(a **int64, b *int64) bool { + if *a == nil && b == nil { + return false + } + if *a == nil || b == nil { + return true + } + return **a != *b +} diff --git a/internal/utils/conditions_test.go b/internal/utils/conditions_test.go new file mode 100644 index 00000000..c18304cf --- /dev/null +++ b/internal/utils/conditions_test.go @@ -0,0 +1,222 @@ +/* +SPDX-FileCopyrightText: Copyright 2024 SAP SE or an SAP affiliate company and cobaltcore-dev contributors +SPDX-License-Identifier: Apache-2.0 + +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 utils + +import ( + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8sacmetav1 "k8s.io/client-go/applyconfigurations/meta/v1" +) + +func TestUtils(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Utils Suite") +} + +var _ = Describe("ConditionFromStatus", func() { + It("copies all fields verbatim", func() { + ts := metav1.NewTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)) + gen := int64(42) + c := metav1.Condition{ + Type: "Ready", + Status: metav1.ConditionTrue, + Reason: "AllGood", + Message: "everything is fine", + LastTransitionTime: ts, + ObservedGeneration: gen, + } + got := ConditionFromStatus(c) + Expect(*got.Type).To(Equal("Ready")) + Expect(*got.Status).To(Equal(metav1.ConditionTrue)) + Expect(*got.Reason).To(Equal("AllGood")) + Expect(*got.Message).To(Equal("everything is fine")) + Expect(*got.LastTransitionTime).To(Equal(ts)) + Expect(*got.ObservedGeneration).To(Equal(gen)) + }) + + It("includes ObservedGeneration even when zero", func() { + c := metav1.Condition{Type: "T", Status: metav1.ConditionFalse, Reason: "R"} + got := ConditionFromStatus(c) + Expect(got.ObservedGeneration).NotTo(BeNil()) + Expect(*got.ObservedGeneration).To(Equal(int64(0))) + }) +}) + +var _ = Describe("SetApplyConfigurationStatusCondition", func() { + ptr := func(s string) *string { return &s } + condStatus := func(s metav1.ConditionStatus) *metav1.ConditionStatus { return &s } + + Describe("nil / empty-type guards", func() { + It("returns false and does not panic on nil slice pointer", func() { + changed := SetApplyConfigurationStatusCondition(nil, + *k8sacmetav1.Condition().WithType("T").WithStatus(metav1.ConditionTrue).WithReason("R")) + Expect(changed).To(BeFalse()) + }) + + It("returns false when Type is nil", func() { + conditions := []k8sacmetav1.ConditionApplyConfiguration{} + changed := SetApplyConfigurationStatusCondition(&conditions, + k8sacmetav1.ConditionApplyConfiguration{}) + Expect(changed).To(BeFalse()) + Expect(conditions).To(BeEmpty()) + }) + + It("returns false when Type is empty string", func() { + conditions := []k8sacmetav1.ConditionApplyConfiguration{} + changed := SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition().WithType("")) + Expect(changed).To(BeFalse()) + Expect(conditions).To(BeEmpty()) + }) + }) + + Describe("appending a new condition", func() { + It("appends when the type is not yet present", func() { + conditions := []k8sacmetav1.ConditionApplyConfiguration{} + changed := SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("Ready"). + WithStatus(metav1.ConditionTrue). + WithReason("OK")) + Expect(changed).To(BeTrue()) + Expect(conditions).To(HaveLen(1)) + Expect(*conditions[0].Type).To(Equal("Ready")) + }) + + It("sets LastTransitionTime to now when not provided", func() { + before := time.Now() + conditions := []k8sacmetav1.ConditionApplyConfiguration{} + SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("T"). + WithStatus(metav1.ConditionTrue). + WithReason("R")) + Expect(conditions[0].LastTransitionTime).NotTo(BeNil()) + Expect(conditions[0].LastTransitionTime.Time).To(BeTemporally(">=", before)) + }) + + It("preserves a caller-supplied LastTransitionTime", func() { + ts := metav1.NewTime(time.Date(2020, 6, 1, 0, 0, 0, 0, time.UTC)) + conditions := []k8sacmetav1.ConditionApplyConfiguration{} + SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("T"). + WithStatus(metav1.ConditionTrue). + WithReason("R"). + WithLastTransitionTime(ts)) + Expect(*conditions[0].LastTransitionTime).To(Equal(ts)) + }) + }) + + Describe("updating an existing condition", func() { + var ( + oldTS metav1.Time + conditions []k8sacmetav1.ConditionApplyConfiguration + ) + + BeforeEach(func() { + oldTS = metav1.NewTime(time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) + conditions = []k8sacmetav1.ConditionApplyConfiguration{ + { + Type: ptr("Ready"), + Status: condStatus(metav1.ConditionFalse), + Reason: ptr("NotReady"), + Message: ptr("waiting"), + LastTransitionTime: &oldTS, + }, + } + }) + + It("updates Status and refreshes LastTransitionTime when Status changes", func() { + before := time.Now() + changed := SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("Ready"). + WithStatus(metav1.ConditionTrue). + WithReason("OK"). + WithMessage("done")) + Expect(changed).To(BeTrue()) + Expect(*conditions[0].Status).To(Equal(metav1.ConditionTrue)) + Expect(conditions[0].LastTransitionTime.Time).To(BeTemporally(">=", before)) + }) + + It("preserves LastTransitionTime when Status is unchanged", func() { + changed := SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("Ready"). + WithStatus(metav1.ConditionFalse). + WithReason("StillNotReady"). + WithMessage("still waiting")) + Expect(changed).To(BeTrue()) // Reason/Message changed + Expect(*conditions[0].LastTransitionTime).To(Equal(oldTS)) + }) + + It("returns false when nothing changed", func() { + changed := SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("Ready"). + WithStatus(metav1.ConditionFalse). + WithReason("NotReady"). + WithMessage("waiting")) + Expect(changed).To(BeFalse()) + Expect(*conditions[0].LastTransitionTime).To(Equal(oldTS)) + }) + + It("updates Reason independently of Status", func() { + changed := SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("Ready"). + WithStatus(metav1.ConditionFalse). + WithReason("DifferentReason"). + WithMessage("waiting")) + Expect(changed).To(BeTrue()) + Expect(*conditions[0].Reason).To(Equal("DifferentReason")) + Expect(*conditions[0].LastTransitionTime).To(Equal(oldTS)) + }) + + It("uses a caller-supplied LastTransitionTime when Status changes", func() { + newTS := metav1.NewTime(time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC)) + SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("Ready"). + WithStatus(metav1.ConditionTrue). + WithReason("OK"). + WithLastTransitionTime(newTS)) + Expect(*conditions[0].LastTransitionTime).To(Equal(newTS)) + }) + + It("does not affect other conditions in the slice", func() { + otherTS := metav1.NewTime(time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)) + conditions = append(conditions, k8sacmetav1.ConditionApplyConfiguration{ + Type: ptr("Other"), + Status: condStatus(metav1.ConditionTrue), + LastTransitionTime: &otherTS, + }) + SetApplyConfigurationStatusCondition(&conditions, + *k8sacmetav1.Condition(). + WithType("Ready"). + WithStatus(metav1.ConditionTrue). + WithReason("OK")) + Expect(*conditions[1].LastTransitionTime).To(Equal(otherTS)) + }) + }) +})