diff --git a/pkg/status/analyzer.go b/pkg/status/analyzer.go index a811f86c..342e952f 100644 --- a/pkg/status/analyzer.go +++ b/pkg/status/analyzer.go @@ -6,6 +6,8 @@ SPDX-License-Identifier: Apache-2.0 package status import ( + "fmt" + "regexp" "strings" "github.com/iancoleman/strcase" @@ -40,22 +42,38 @@ func NewStatusAnalyzer(reconcilerName string) StatusAnalyzer { // Implement the StatusAnalyzer interface. func (s *statusAnalyzer) ComputeStatus(object *unstructured.Unstructured) (Status, error) { + var extraConditions []string + if hint, ok := object.GetAnnotations()[s.reconcilerName+"/"+types.AnnotationKeySuffixStatusHint]; ok { object = object.DeepCopy() - for _, hint := range strings.Split(hint, ",") { - switch strcase.ToKebab(hint) { + var key, value string + var hasValue bool + if match := regexp.MustCompile(`^([^=]+)=(.*)$`).FindStringSubmatch(hint); match != nil { + key = match[1] + value = match[2] + hasValue = true + } else { + key = hint + } + switch strcase.ToKebab(key) { case types.StatusHintHasObservedGeneration: + if hasValue { + return UnknownStatus, fmt.Errorf("status hint %s does not take a value", types.StatusHintHasObservedGeneration) + } _, found, err := unstructured.NestedInt64(object.Object, "status", "observedGeneration") if err != nil { return UnknownStatus, err } if !found { - if err := unstructured.SetNestedField(object.Object, -1, "status", "observedGeneration"); err != nil { + if err := unstructured.SetNestedField(object.Object, int64(-1), "status", "observedGeneration"); err != nil { return UnknownStatus, err } } case types.StatusHintHasReadyCondition: + if hasValue { + return UnknownStatus, fmt.Errorf("status hint %s does not take a value", types.StatusHintHasReadyCondition) + } foundReadyCondition := false conditions, found, err := unstructured.NestedSlice(object.Object, "status", "conditions") if err != nil { @@ -85,6 +103,13 @@ func (s *statusAnalyzer) ComputeStatus(object *unstructured.Unstructured) (Statu return UnknownStatus, err } } + case types.StatusHintConditions: + if !hasValue { + return UnknownStatus, fmt.Errorf("status hint %s requires a value", types.StatusHintConditions) + } + extraConditions = append(extraConditions, strings.Split(value, ";")...) + default: + return UnknownStatus, fmt.Errorf("unknown status hint %s", key) } } } @@ -93,11 +118,33 @@ func (s *statusAnalyzer) ComputeStatus(object *unstructured.Unstructured) (Statu if err != nil { return UnknownStatus, err } + status := Status(res.Status) + + if status == CurrentStatus && len(extraConditions) > 0 { + objc, err := kstatus.GetObjectWithConditions(object.UnstructuredContent()) + if err != nil { + return UnknownStatus, err + } + for _, condition := range extraConditions { + found := false + for _, cond := range objc.Status.Conditions { + if cond.Type == condition { + found = true + if cond.Status != corev1.ConditionTrue { + status = InProgressStatus + } + } + } + if !found { + status = InProgressStatus + } + } + } switch object.GroupVersionKind() { case schema.GroupVersionKind{Group: "batch", Version: "v1", Kind: "Job"}: // other than kstatus we want to consider jobs as InProgress if its pods are still running, resp. did not (yet) finish successfully - if res.Status == kstatus.CurrentStatus { + if status == CurrentStatus { done := false objc, err := kstatus.GetObjectWithConditions(object.UnstructuredContent()) if err != nil { @@ -114,10 +161,10 @@ func (s *statusAnalyzer) ComputeStatus(object *unstructured.Unstructured) (Statu } } if !done { - res.Status = kstatus.InProgressStatus + status = InProgressStatus } } } - return Status(res.Status), nil + return status, nil } diff --git a/pkg/status/analyzer_test.go b/pkg/status/analyzer_test.go new file mode 100644 index 00000000..eacec640 --- /dev/null +++ b/pkg/status/analyzer_test.go @@ -0,0 +1,148 @@ +/* +SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and component-operator-runtime contributors +SPDX-License-Identifier: Apache-2.0 +*/ + +package status_test + +import ( + "strings" + + 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" + kstatus "sigs.k8s.io/cli-utils/pkg/kstatus/status" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/sap/component-operator-runtime/pkg/status" +) + +var _ = Describe("testing: analyzer.go", func() { + var analyzer status.StatusAnalyzer + + BeforeEach(func() { + analyzer = status.NewStatusAnalyzer("test") + }) + + Context("xxx", func() { + BeforeEach(func() { + }) + + DescribeTable("testing: ComputeStatus()", + func(generation int, observedGeneration int, conditions map[kstatus.ConditionType]corev1.ConditionStatus, hintObservedGeneration bool, hintReadyCondition bool, hintConditions []string, expectedStatus status.Status) { + obj := Object{ + ObjectMeta: metav1.ObjectMeta{ + Generation: int64(generation), + }, + Status: ObjectStatus{ + ObservedGeneration: int64(observedGeneration), + }, + } + for name, status := range conditions { + obj.Status.Conditions = append(obj.Status.Conditions, kstatus.Condition{ + Type: name, + Status: status, + }) + } + var hints []string + if hintObservedGeneration { + hints = append(hints, "has-observed-generation") + } + if hintReadyCondition { + hints = append(hints, "has-ready-condition") + } + if len(hintConditions) > 0 { + hints = append(hints, "conditions="+strings.Join(hintConditions, ";")) + } + if len(hints) > 0 { + obj.Annotations = map[string]string{ + "test/status-hint": strings.Join(hints, ","), + } + } + unstructuredContent, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&obj) + Expect(err).NotTo(HaveOccurred()) + unstructuredObj := &unstructured.Unstructured{Object: unstructuredContent} + + computedStatus, err := analyzer.ComputeStatus(unstructuredObj) + Expect(err).NotTo(HaveOccurred()) + + Expect(computedStatus).To(Equal(expectedStatus)) + }, + + Entry(nil, 3, 0, nil, false, false, nil, status.CurrentStatus), + Entry(nil, 3, 1, nil, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, nil, false, false, nil, status.CurrentStatus), + Entry(nil, 3, 0, nil, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 1, nil, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, nil, true, false, nil, status.CurrentStatus), + + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionUnknown}, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionUnknown}, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionUnknown}, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionUnknown}, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionUnknown}, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionUnknown}, true, false, nil, status.InProgressStatus), + + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionFalse}, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionFalse}, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionFalse}, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionFalse}, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionFalse}, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionFalse}, true, false, nil, status.InProgressStatus), + + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionTrue}, false, false, nil, status.CurrentStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionTrue}, false, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionTrue}, false, false, nil, status.CurrentStatus), + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionTrue}, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionTrue}, true, false, nil, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Ready": corev1.ConditionTrue}, true, false, nil, status.CurrentStatus), + + Entry(nil, 3, 0, nil, false, true, nil, status.InProgressStatus), + Entry(nil, 3, 1, nil, false, true, nil, status.InProgressStatus), + Entry(nil, 3, 3, nil, false, true, nil, status.InProgressStatus), + Entry(nil, 3, 0, nil, true, true, nil, status.InProgressStatus), + Entry(nil, 3, 1, nil, true, true, nil, status.InProgressStatus), + Entry(nil, 3, 3, nil, true, true, nil, status.InProgressStatus), + + Entry(nil, 3, 0, nil, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 1, nil, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, nil, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 0, nil, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 1, nil, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, nil, true, false, []string{"Test"}, status.InProgressStatus), + + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, true, false, []string{"Test"}, status.InProgressStatus), + + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionFalse}, true, false, []string{"Test"}, status.InProgressStatus), + + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionTrue}, false, false, []string{"Test"}, status.CurrentStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionTrue}, false, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionTrue}, false, false, []string{"Test"}, status.CurrentStatus), + Entry(nil, 3, 0, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionTrue}, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 1, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionTrue}, true, false, []string{"Test"}, status.InProgressStatus), + Entry(nil, 3, 3, map[kstatus.ConditionType]corev1.ConditionStatus{"Test": corev1.ConditionTrue}, true, false, []string{"Test"}, status.CurrentStatus), + ) + }) +}) + +type Object struct { + metav1.ObjectMeta `json:"metadata,omitempty"` + Status ObjectStatus `json:"status"` +} + +type ObjectStatus struct { + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + Conditions []kstatus.Condition `json:"conditions,omitempty"` +} diff --git a/pkg/status/suite_test.go b/pkg/status/suite_test.go new file mode 100644 index 00000000..d5dad396 --- /dev/null +++ b/pkg/status/suite_test.go @@ -0,0 +1,18 @@ +/* +SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and component-operator-runtime contributors +SPDX-License-Identifier: Apache-2.0 +*/ + +package status_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestComponent(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Component Suite") +} diff --git a/pkg/types/constants.go b/pkg/types/constants.go index 7aa99d51..ff9c8364 100644 --- a/pkg/types/constants.go +++ b/pkg/types/constants.go @@ -48,4 +48,5 @@ const ( const ( StatusHintHasObservedGeneration = "has-observed-generation" StatusHintHasReadyCondition = "has-ready-condition" + StatusHintConditions = "conditions" ) diff --git a/website/content/en/docs/concepts/dependents.md b/website/content/en/docs/concepts/dependents.md index 9e3c887d..e72fc829 100644 --- a/website/content/en/docs/concepts/dependents.md +++ b/website/content/en/docs/concepts/dependents.md @@ -42,7 +42,8 @@ To support such cases, the `Generator` implementation can set the following anno - `mycomponent-operator.mydomain.io/delete-order` (optional): the wave by which this object will be deleted; that is, if the dependent is no longer part of the component, or if the whole component is being deleted; dependents will be deleted wave by wave; that is, objects of the same wave will be deleted in a canonical order, and the reconciler will only proceed to the next wave if all objects of previous saves are gone; specified orders can be negative or positive numbers between -32768 and 32767, objects with no explicit order set are treated as if they would specify order 0; note that the delete order is completely independent of the apply order - `mycomponent-operator.mydomain.io/status-hint` (optional): a comma-separated list of hints that may help the framework to properly identify the state of the annotated dependent object; currently, the following hints are possible: - `has-observed-generation`: tells the framework that the dependent object has a `status.observedGeneration` field, even if it is not (yet) set by the responsible controller (some controllers are known to set the observed generation lazily, with the consequence that there is a period right after creation of the dependent object, where the field is missing in the dependent's status) - - `has-ready-condition`: tells the framework to count with a ready condition; if it is absent, the condition state will be considered as `Unknown` + - `has-ready-condition`: tells the framework to count with a ready condition; if it is absent, the condition status will be considered as `Unknown` + - `conditions`: semicolon-separated list of additional conditions that must be present and have a `True` status in order to make the overall status ready Note that, in the above paragraph, `mycomponent-operator.mydomain.io` has to be replaced with whatever was passed as `name` when calling `NewReconciler()`.