diff --git a/util/conditions/experimental/aggregate.go b/util/conditions/experimental/aggregate.go new file mode 100644 index 000000000000..0be2f93fa2af --- /dev/null +++ b/util/conditions/experimental/aggregate.go @@ -0,0 +1,127 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// AggregateOption is some configuration that modifies options for a aggregate request. +type AggregateOption interface { + // ApplyToAggregate applies this configuration to the given aggregate options. + ApplyToAggregate(option *AggregateOptions) +} + +// AggregateOptions allows to set options for the aggregate operation. +type AggregateOptions struct { + mergeStrategy MergeStrategy + overrideType string +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *AggregateOptions) ApplyOptions(opts []AggregateOption) *AggregateOptions { + for _, opt := range opts { + opt.ApplyToAggregate(o) + } + return o +} + +// NewAggregateCondition aggregates a condition from a list of objects; if the given condition does not exist in the source object, +// missing conditions are considered Unknown, reason NotYetReported. +// +// By default, the Aggregate condition has the same type of the source condition, but this can be changed by using +// the OverrideType option. +// +// Additionally, it is possible to inject custom merge strategies using the WithMergeStrategy option. +func NewAggregateCondition(objects []runtime.Object, conditionType string, opts ...AggregateOption) (*metav1.Condition, error) { + aggregateOpt := &AggregateOptions{ + mergeStrategy: newDefaultMergeStrategy(), + } + aggregateOpt.ApplyOptions(opts) + + conditionsInScope := make([]ConditionWithOwnerInfo, 0, len(objects)) + for _, obj := range objects { + // TODO: consider if we want to aggregate all errors before returning + conditions, err := getConditionsWithOwnerInfo(obj) + if err != nil { + return nil, err + } + + // Drops all the conditions not in scope for the merge operation + hasConditionType := false + for _, condition := range conditions { + if condition.Type != conditionType { + continue + } + conditionsInScope = append(conditionsInScope, condition) + hasConditionType = true + } + + // Add the expected conditions if it does not exist, so we are compliant with K8s guidelines + // (all missing conditions should be considered unknown). + if !hasConditionType { + conditionOwner := getConditionOwnerInfo(obj) + + conditionsInScope = append(conditionsInScope, ConditionWithOwnerInfo{ + OwnerResource: conditionOwner, + Condition: metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported from %s", conditionType, conditionOwner.String()), + }, + }) + } + } + + status, reason, message, err := aggregateOpt.mergeStrategy.Merge( + conditionsInScope, + []string{conditionType}, + nil, // negative conditions + false, // step counter + ) + if err != nil { + return nil, err + } + + c := &metav1.Condition{ + Type: conditionType, + Status: status, + Reason: reason, + Message: message, + } + + if aggregateOpt.overrideType != "" { + c.Type = aggregateOpt.overrideType + } + + return c, err +} + +// SetAggregateCondition is a convenience method that calls NewAggregateCondition to create an aggregate condition from the source objects, +// and then calls Set to add the new condition to the target object. +func SetAggregateCondition(sourceObjs []runtime.Object, targetObj runtime.Object, conditionType string, opts ...AggregateOption) error { + mirrorCondition, err := NewAggregateCondition(sourceObjs, conditionType, opts...) + if err != nil { + return err + } + return Set(targetObj, *mirrorCondition) +} diff --git a/util/conditions/experimental/aggregate_test.go b/util/conditions/experimental/aggregate_test.go new file mode 100644 index 000000000000..18c7dc01fa27 --- /dev/null +++ b/util/conditions/experimental/aggregate_test.go @@ -0,0 +1,226 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestAggregate(t *testing.T) { + tests := []struct { + name string + conditions [][]metav1.Condition + conditionType string + options []AggregateOption + want *metav1.Condition + }{ + { + name: "One issue", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj2 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: "Reason-1", // Picking the reason from the only existing issue + Message: "(False): Message-1 from default/obj0", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Same issue from up to tree objects", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj4 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: ManyIssuesReason, // Using a generic reason + Message: "(False): Message-1 from default/obj0, default/obj1, default/obj2", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Same issue from more than tree objects", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-B", Message: "Message-1"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Message: "Message-99"}}, // obj6 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: ManyIssuesReason, // Using a generic reason + Message: "(False): Message-1 from default/obj0, default/obj1, default/obj2 and 2 other V1Beta2ResourceWithConditions", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Up to three different issue messages", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-3", Message: "Message-3"}}, // obj6 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj7 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: ManyIssuesReason, // Using a generic reason + Message: "(False): Message-1 from default/obj0, default/obj3, default/obj4; (False): Message-2 from default/obj1, default/obj2; (False): Message-3 from default/obj5", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "More than three different issue messages", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-4", Message: "Message-4"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-5", Message: "Message-5"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-3", Message: "Message-3"}}, // obj6 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj7 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: ManyIssuesReason, // Using a generic reason + Message: "(False): Message-1 from default/obj0, default/obj4; (False): Message-2 from default/obj1; (False): Message-3 from default/obj5; other 2 V1Beta2ResourceWithConditions with issues", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Less than 2 issue messages and unknown message", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj4 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: ManyIssuesReason, // Using a generic reason + Message: "(False): Message-1 from default/obj0; (False): Message-2 from default/obj1; (Unknown): Message-3 from default/obj2", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "At least 3 issue messages and unknown message", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-4", Message: "Message-4"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj4 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: ManyIssuesReason, // Using a generic reason + Message: "(False): Message-1 from default/obj0; (False): Message-2 from default/obj1; (False): Message-4 from default/obj3; other 1 V1Beta2ResourceWithConditions unknown", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "unknown messages", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-2", Message: "Message-2"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-4", Message: "Message-4"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-5", Message: "Message-5"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-1", Message: "Message-1"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj6 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj7 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // False because there is one issue + Reason: ManyUnknownsReason, // Using a generic reason + Message: "(Unknown): Message-1 from default/obj0, default/obj4; (Unknown): Message-2 from default/obj1; (Unknown): Message-3 from default/obj5; other 2 V1Beta2ResourceWithConditions unknown", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "info messages", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-2", Message: "Message-2"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-4", Message: "Message-4"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-5", Message: ""}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-3", Message: "Message-3"}}, // obj6 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionTrue, // False because there is one issue + Reason: ManyInfoReason, // Using a generic reason + Message: "(True): Message-1 from default/obj0, default/obj4; (True): Message-2 from default/obj1; (True): Message-3 from default/obj5; other 1 V1Beta2ResourceWithConditions with info messages", // messages from all the issues & unknown conditions (info dropped) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + objs := make([]runtime.Object, 0, len(tt.conditions)) + for i := range tt.conditions { + objs = append(objs, &V1Beta2ResourceWithConditions{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: fmt.Sprintf("obj%d", i), + }, + Status: struct{ Conditions []metav1.Condition }{ + Conditions: tt.conditions[i], + }, + }) + } + + got, err := NewAggregateCondition(objs, tt.conditionType, tt.options...) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(got).To(Equal(tt.want)) + }) + } +} diff --git a/util/conditions/experimental/doc.go b/util/conditions/experimental/doc.go new file mode 100644 index 000000000000..3e9771f8febf --- /dev/null +++ b/util/conditions/experimental/doc.go @@ -0,0 +1,18 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental implements experimental condition utilities. +package experimental diff --git a/util/conditions/experimental/getter.go b/util/conditions/experimental/getter.go new file mode 100644 index 000000000000..e1a2257956a8 --- /dev/null +++ b/util/conditions/experimental/getter.go @@ -0,0 +1,205 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "reflect" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// Get returns a conditions from the object. +// +// Get support retrieving conditions from objects at different stages of the transition to metav1.condition type: +// - Objects with metav1.condition in status.experimental conditions +// - Objects with metav1.condition in status.conditions +// +// In case the object does not have metav1.conditions, Get tries to read clusterv1.condition from status.conditions +// and convert them to metav1.conditions. +func Get(obj runtime.Object, conditionType string) (*metav1.Condition, error) { + conditions, err := GetAll(obj) + if err != nil { + return nil, err + } + return meta.FindStatusCondition(conditions, conditionType), nil +} + +// GetAll returns all the conditions from the object. +// +// GetAll support retrieving conditions from objects at different stages of the transition to metav1.condition type: +// - Objects with metav1.condition in status.experimental conditions +// - Objects with metav1.condition in status.conditions +// +// In case the object does not have metav1.conditions, GetAll tries to read clusterv1.condition from status.conditions +// and convert them to metav1.conditions. +func GetAll(obj runtime.Object) ([]metav1.Condition, error) { + if obj == nil { + return nil, errors.New("obj cannot be nil") + } + + switch obj.(type) { + case runtime.Unstructured: + return getFromUnstructuredObject(obj) + default: + return getFromTypedObject(obj) + } +} + +func getFromUnstructuredObject(obj runtime.Object) ([]metav1.Condition, error) { + u, ok := obj.(runtime.Unstructured) + if !ok { + // NOTE: this should not happen due to the type assertion before calling this fun + return nil, errors.New("obj cannot be converted to runtime.Unstructured") + } + + if !reflect.ValueOf(u).Elem().IsValid() { + return nil, errors.New("obj cannot be nil") + } + + value, _, _ := unstructured.NestedFieldNoCopy(u.UnstructuredContent(), "status", "experimentalConditions") + if conditions, ok := value.([]interface{}); ok { + return convertUnstructuredConditions(conditions), nil + } + + value, _, _ = unstructured.NestedFieldNoCopy(u.UnstructuredContent(), "status", "conditions") + if conditions, ok := value.([]interface{}); ok { + return convertUnstructuredConditions(conditions), nil + } + + return nil, errors.New("obj must have Status with one of Conditions or ExperimentalConditions") +} + +func convertUnstructuredConditions(conditions []interface{}) []metav1.Condition { + if conditions == nil { + return nil + } + + convertedConditions := make([]metav1.Condition, 0, len(conditions)) + for _, c := range conditions { + cMap, ok := c.(map[string]interface{}) + if !ok || cMap == nil { + // TODO: think about returning an error in this case and when type of status are not set (as a signal it is a condition type) + continue + } + + var conditionType string + if v, ok := cMap["type"]; ok { + conditionType = v.(string) + } + + var status string + if v, ok := cMap["status"]; ok { + status = v.(string) + } + + var observedGeneration int64 + if v, ok := cMap["observedGeneration"]; ok { + observedGeneration = v.(int64) + } + + var lastTransitionTime metav1.Time + if v, ok := cMap["lastTransitionTime"]; ok { + _ = lastTransitionTime.UnmarshalQueryParameter(v.(string)) + } + + var reason string + if v, ok := cMap["reason"]; ok { + reason = v.(string) + } + + var message string + if v, ok := cMap["message"]; ok { + message = v.(string) + } + + convertedConditions = append(convertedConditions, metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionStatus(status), + ObservedGeneration: observedGeneration, + LastTransitionTime: lastTransitionTime, + Reason: reason, + Message: message, + }) + } + return convertedConditions +} + +func getFromTypedObject(obj runtime.Object) ([]metav1.Condition, error) { + ptr := reflect.ValueOf(obj) + if ptr.Kind() != reflect.Pointer { + return nil, errors.New("obj must be a pointer") + } + + elem := ptr.Elem() + if !elem.IsValid() { + return nil, errors.New("obj must be a valid value (non zero value of its type)") + } + + statusField := elem.FieldByName("Status") + if statusField == (reflect.Value{}) { + return nil, errors.New("obj must have a Status field") + } + + // Get conditions. + // NOTE: Given that we allow providers to migrate at different speed, it is required to support objects at the different stage of the transition from legacy conditions to metav1.conditions. + // In order to handle this, first try to read Status.ExperimentalConditions, then Status.Conditions; for Status.Conditions, also support conversion from legacy conditions. + // The ExperimentalConditions branch and the conversion from legacy conditions should be dropped when v1beta1 API are removed. + + if conditionField := statusField.FieldByName("ExperimentalConditions"); conditionField != (reflect.Value{}) { + v1beta2Conditions, ok := conditionField.Interface().([]metav1.Condition) + if !ok { + return nil, errors.New("obj.Status.ExperimentalConditions must be of type []metav1.conditions") + } + return v1beta2Conditions, nil + } + + if conditionField := statusField.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + conditions, ok := conditionField.Interface().([]metav1.Condition) + if ok { + return conditions, nil + } + + return convertFromV1Beta1Conditions(conditionField) + } + + return nil, errors.New("obj.Status must have one of Conditions or ExperimentalConditions") +} + +func convertFromV1Beta1Conditions(conditionField reflect.Value) ([]metav1.Condition, error) { + v1betaConditions, ok := conditionField.Interface().(clusterv1.Conditions) + if !ok { + return nil, errors.New("obj.Status.Conditions must be of type []metav1.conditions or []clusterv1.Condition") + } + + convertedConditions := make([]metav1.Condition, len(v1betaConditions)) + for i, c := range v1betaConditions { + convertedConditions[i] = metav1.Condition{ + Type: string(c.Type), + Status: metav1.ConditionStatus(c.Status), + LastTransitionTime: c.LastTransitionTime, + Reason: c.Reason, + Message: c.Message, + } + } + return convertedConditions, nil +} diff --git a/util/conditions/experimental/getter_test.go b/util/conditions/experimental/getter_test.go new file mode 100644 index 000000000000..ce189d07db7d --- /dev/null +++ b/util/conditions/experimental/getter_test.go @@ -0,0 +1,377 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + . "github.com/onsi/gomega" + 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" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +type ObjWithoutStatus struct { + metav1.TypeMeta + metav1.ObjectMeta +} + +func (f *ObjWithoutStatus) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type ObjWithStatusWithoutConditions struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + } +} + +func (f *ObjWithStatusWithoutConditions) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type ObjWithWrongConditionType struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions []string + } +} + +func (f *ObjWithWrongConditionType) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type ObjWithWrongExperimentalConditionType struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions clusterv1.Conditions + ExperimentalConditions []string + } +} + +func (f *ObjWithWrongExperimentalConditionType) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type V1Beta1ResourceWithLegacyConditions struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions clusterv1.Conditions + } +} + +func (f *V1Beta1ResourceWithLegacyConditions) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type V1Beta1ResourceWithLegacyAndExperimentalConditionsV1 struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions clusterv1.Conditions + ExperimentalConditions []metav1.Condition + } +} + +func (f *V1Beta1ResourceWithLegacyAndExperimentalConditionsV1) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type V1Beta2ResourceWithConditionsAndBackwardCompatibleConditions struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions []metav1.Condition + BackwardCompatibility struct { + Conditions clusterv1.Conditions + } + } +} + +func (f *V1Beta2ResourceWithConditionsAndBackwardCompatibleConditions) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type V1Beta2ResourceWithConditions struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions []metav1.Condition + } +} + +func (f *V1Beta2ResourceWithConditions) DeepCopyObject() runtime.Object { + panic("implement me") +} + +func TestGetAll(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + + t.Run("fails with nil", func(t *testing.T) { + g := NewWithT(t) + + _, err := GetAll(nil) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("fails for nil object", func(t *testing.T) { + g := NewWithT(t) + var foo *V1Beta1ResourceWithLegacyConditions + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + var fooUnstructured *unstructured.Unstructured + + _, err = GetAll(fooUnstructured) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("fails for object without status", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithoutStatus{} + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + _, err = GetAll(&unstructured.Unstructured{Object: fooUnstructured}) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("fails for object with status without conditions or experimental conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithStatusWithoutConditions{} + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + _, err = GetAll(&unstructured.Unstructured{Object: fooUnstructured}) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("fails for object with wrong condition type", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongConditionType{} + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + // TODO: think about how unstructured can detect wrong type + }) + + t.Run("fails for object with wrong experimental condition type", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongExperimentalConditionType{} + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + // TODO: think about how unstructured can detect wrong type + }) + + t.Run("v1beta object with legacy conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta1ResourceWithLegacyConditions{ + Status: struct{ Conditions clusterv1.Conditions }{Conditions: clusterv1.Conditions{ + { + Type: "fooCondition", + Status: corev1.ConditionTrue, + LastTransitionTime: now, + }, + { + Type: "fooCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + }, + }}, + } + + expect := []metav1.Condition{ + { + Type: string(foo.Status.Conditions[0].Type), + Status: metav1.ConditionStatus(foo.Status.Conditions[0].Status), + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + }, + { + Type: string(foo.Status.Conditions[1].Type), + Status: metav1.ConditionStatus(foo.Status.Conditions[1].Status), + LastTransitionTime: foo.Status.Conditions[1].LastTransitionTime, + Reason: foo.Status.Conditions[1].Reason, + Message: foo.Status.Conditions[1].Message, + }, + } + + got, err := GetAll(foo) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err = GetAll(&unstructured.Unstructured{Object: fooUnstructured}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + }) + + t.Run("v1beta1 object with both legacy and experimental conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta1ResourceWithLegacyAndExperimentalConditionsV1{ + Status: struct { + Conditions clusterv1.Conditions + ExperimentalConditions []metav1.Condition + }{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + ExperimentalConditions: []metav1.Condition{ + { + Type: "fooCondition", + Status: metav1.ConditionTrue, + ObservedGeneration: 10, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + }, + }, + }, + } + + expect := []metav1.Condition{ + { + Type: foo.Status.ExperimentalConditions[0].Type, + Status: foo.Status.ExperimentalConditions[0].Status, + LastTransitionTime: foo.Status.ExperimentalConditions[0].LastTransitionTime, + ObservedGeneration: foo.Status.ExperimentalConditions[0].ObservedGeneration, + Reason: foo.Status.ExperimentalConditions[0].Reason, + Message: foo.Status.ExperimentalConditions[0].Message, + }, + } + + got, err := GetAll(foo) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err = GetAll(&unstructured.Unstructured{Object: fooUnstructured}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + }) + + t.Run("v1beta2 object with conditions and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta2ResourceWithConditionsAndBackwardCompatibleConditions{ + Status: struct { + Conditions []metav1.Condition + BackwardCompatibility struct{ Conditions clusterv1.Conditions } + }{ + Conditions: []metav1.Condition{ + { + Type: "fooCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + }, + }, + BackwardCompatibility: struct{ Conditions clusterv1.Conditions }{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + } + + expect := []metav1.Condition{ + { + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + }, + } + + got, err := GetAll(foo) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err = GetAll(&unstructured.Unstructured{Object: fooUnstructured}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + }) + + t.Run("v1beta2 object with conditions (end state)", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta2ResourceWithConditions{ + Status: struct { + Conditions []metav1.Condition + }{ + Conditions: []metav1.Condition{ + { + Type: "fooCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + }, + }, + }, + } + + expect := []metav1.Condition{ + { + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + }, + } + + got, err := GetAll(foo) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + got, err = GetAll(&unstructured.Unstructured{Object: fooUnstructured}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(expect), cmp.Diff(got, expect)) + }) +} diff --git a/util/conditions/experimental/merge_strategies.go b/util/conditions/experimental/merge_strategies.go new file mode 100644 index 000000000000..96aee11720c1 --- /dev/null +++ b/util/conditions/experimental/merge_strategies.go @@ -0,0 +1,408 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/gobuffalo/flect" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/klog/v2" +) + +const ( + // ManyIssuesReason is set on conditions generated during aggregate or summary operations when many conditions/objects are reporting issues. + ManyIssuesReason = "MoreThanOneIssuesReported" + + // ManyUnknownsReason is set on conditions generated during aggregate or summary operations when many conditions/objects are reporting unknown. + ManyUnknownsReason = "MoreThanOneUnknownReported" + + // ManyInfoReason is set on conditions generated during aggregate or summary operations when many conditions/objects are reporting info. + ManyInfoReason = "MoreThanOneInfoReported" +) + +// MergeStrategy defines a strategy used to merge conditions during the aggregate or summary operation. +type MergeStrategy interface { + // Merge all the condition in input. + // + // It is up to the caller to ensure that all the expected conditions exist (e.g. by adding new conditions with status Unknown). + // Condition in input must be of the given conditionTypes (other condition types must be discarded); + // + // The list of conditionTypes has an implicit order; it is up to the implementation of merge to use this info or not. + // If negativeConditionTypes are in scope, the implementation of merge should treat them accordingly. + // + // It required, the implementation of merge must add a stepCounter to the output message. + Merge(conditions []ConditionWithOwnerInfo, conditionTypes []string, negativeConditionTypes sets.Set[string], stepCounter bool) (status metav1.ConditionStatus, reason, message string, err error) +} + +// ConditionWithOwnerInfo is a wrapper metav1.Condition with addition ConditionOwnerInfo. +// Those info can be used when generating the message resulting from the merge operation. +type ConditionWithOwnerInfo struct { + OwnerResource ConditionOwnerInfo + metav1.Condition +} + +// ConditionOwnerInfo contains info about the object that owns the condition. +type ConditionOwnerInfo struct { + metav1.TypeMeta + Namespace string + Name string +} + +// defaultMergeStrategy defines the default merge strategy for Cluster API conditions. +type defaultMergeStrategy struct{} + +type mergePriority uint8 + +const ( + issueMergePriority mergePriority = iota + unknownMergePriority + infoMergePriority +) + +func newDefaultMergeStrategy() MergeStrategy { + return &defaultMergeStrategy{} +} + +// Merge all the condition in input based on a strategy that surfaces issue first, then unknown conditions, the info (if none of issues and unknown condition exists). +// - issues: conditions with positive polarity (normal True) and status False or conditions with negative polarity (normal False) and status True. +// - unknown: conditions with status unknown. +// - info: conditions with positive polarity (normal True) and status True or conditions with negative polarity (normal False) and status False. +func (d *defaultMergeStrategy) Merge(conditions []ConditionWithOwnerInfo, conditionTypes []string, negativeConditionTypes sets.Set[string], stepCounter bool) (status metav1.ConditionStatus, reason, message string, err error) { + // Infer which operation is calling this func, so it is possible to use different strategies for computing the message for the target condition. + // - When merge should consider a single condition type, we can assume this func is called within an aggregate operation + // (Aggregate should merge the same condition across many objects) + isAggregateOperation := len(conditionTypes) == 1 + + // - Otherwise we can assume this func is called within an summary operation + // (Aggregate should merge the same condition across many objects) + isSummaryOperation := !isAggregateOperation + + // sortConditions the relevance defined by the users (the order of condition types), LastTransition time (older first). + sortConditions(conditions, conditionTypes) + + issueConditions, unknownConditions, infoConditions := splitConditionsByPriority(conditions, negativeConditionTypes) + + // Compute the status for the target condition: + // Note: This function always return a condition with positive polarity. + // - If the top group are issues, use false + // - If the top group are unknown, use unknown + // - If the top group are info, use true + switch { + case len(issueConditions) > 0: + status = metav1.ConditionFalse + case len(unknownConditions) > 0: + status = metav1.ConditionUnknown + case len(infoConditions) > 0: + status = metav1.ConditionTrue + } + + // Compute the reason for the target condition: + // - In case there is only one condition in the top group, use the reason from this condition + // - In case there are more than one condition in the top group, use a generic reason (for the target group) + switch { + case len(issueConditions) == 1: + reason = issueConditions[0].Reason + case len(issueConditions) > 1: + reason = ManyIssuesReason + case len(unknownConditions) == 1: + reason = unknownConditions[0].Reason + case len(unknownConditions) > 1: + reason = ManyUnknownsReason + case len(infoConditions) == 1: + reason = infoConditions[0].Reason + case len(infoConditions) > 1: + reason = ManyInfoReason + } + + // Compute the message for the target condition, which is optimized for the operation being performed. + + // When performing the summary operation, usually we are merging a small set of conditions form the same object, + // Considering the small number of conditions, involved it is acceptable/preferred to provide as much detail + // as possible about the messages from the conditions being merged. + // + // Accordingly, the resulting message is composed by all the messages from conditions classified as issues/unknown; + // messages from conditions classified as info are included only if there are no issues/unknown. + // + // e.g. Condition-B (False): Message-B; Condition-!C (True): Message-!C; Condition-A (Unknown): Message-A + // + // When including messages from conditions, they are sorted by issue/unknown and by the implicit order of condition types + // provided by the user (it is consider as order of relevance). + if isSummaryOperation { + messages := []string{} + for _, condition := range append(issueConditions, append(unknownConditions, infoConditions...)...) { + priority := getPriority(condition.Condition, negativeConditionTypes) + if priority == infoMergePriority { + // Drop info messages when we are surfacing issues on unknown. + if status != metav1.ConditionTrue { + continue + } + // Drop info conditions with empty messages. + if condition.Message == "" { + continue + } + } + + m := fmt.Sprintf("%s (%s)", condition.Type, condition.Status) + if condition.Message != "" { + m += fmt.Sprintf(": %s", condition.Message) + } + messages = append(messages, m) + } + + // Prepend the step counter if required. + if stepCounter { + totalSteps := len(conditionTypes) + stepsCompleted := len(infoConditions) + + messages = append([]string{fmt.Sprintf("%d of %d completed", stepsCompleted, totalSteps)}, messages...) + } + + message = strings.Join(messages, "; ") + } + + // When performing the aggregate operation, we are merging one single condition from potentially many objects. + // Considering the high number of conditions involved, the messages from the conditions being merged must be filtered/summarized + // using rules designed to surface the most important issues. + // + // Accordingly, the resulting message is composed by only three messages from conditions classified as issues/unknown; + // instead three messages from conditions classified as info are included only if there are no issues/unknown. + // + // The number of objects reporting the same message determine the order used to pick the messages to be shown; + // For each message it is reported a list of max 3 objects reporting the message; if more objects are reporting the same + // message, the number of those objects is surfaced. + // + // e.g. (False): Message-1 from default/obj0, default/obj1, default/obj2 and 2 other Objects + // + // If there are other objects - objects not included in the list above - reporting issues/unknown (or info there no issues/unknown), + // the number of those objects is surfaced. + // + // e.g. ...; other 2 Objects with issues; other 1 Objects unknown + // + if isAggregateOperation { + // Gets the kind to be used when composing messages. + // NOTE: This assume all the objects in an aggregate operation are of the same kind. + kind := "" + if len(conditions) > 0 { + kind = flect.Pluralize(conditions[0].OwnerResource.Kind) + } + + n := 3 + messages := []string{} + + // Get max n issue messages, decrement n, and track if there are other objects reporting issues not included in the messages. + if len(issueConditions) > 0 { + issueMessages, otherIssues := aggregateMessages(issueConditions, &n, kind, false) + + messages = append(messages, issueMessages...) + if otherIssues > 0 { + messages = append(messages, fmt.Sprintf("other %d %s with issues", otherIssues, kind)) + } + } + + // Get max n unknown messages, decrement n, and track if there are other objects reporting unknown not included in the messages. + if len(unknownConditions) > 0 { + unknownMessages, otherUnknown := aggregateMessages(unknownConditions, &n, kind, false) + + messages = append(messages, unknownMessages...) + if otherUnknown > 0 { + messages = append(messages, fmt.Sprintf("other %d %s unknown", otherUnknown, kind)) + } + } + + // Only if there are no issue or unknown, + // Get max n info messages, decrement n, and track if there are other objects reporting info not included in the messages. + if len(issueConditions) == 0 && len(unknownConditions) == 0 && len(infoConditions) > 0 { + infoMessages, infoUnknown := aggregateMessages(infoConditions, &n, kind, true) + + messages = append(messages, infoMessages...) + if infoUnknown > 0 { + messages = append(messages, fmt.Sprintf("other %d %s with info messages", infoUnknown, kind)) + } + } + + message = strings.Join(messages, "; ") + } + + return status, reason, message, nil +} + +// sortConditions by condition types order, LastTransitionTime +// (the order of relevance defined by the users, the oldest first). +func sortConditions(conditions []ConditionWithOwnerInfo, orderedConditionTypes []string) { + conditionOrder := make(map[string]int, len(orderedConditionTypes)) + for i, conditionType := range orderedConditionTypes { + conditionOrder[conditionType] = i + } + + sort.SliceStable(conditions, func(i, j int) bool { + // Sort by condition order (user defined, useful when computing summary of different conditions from the same object) + return conditionOrder[conditions[i].Type] < conditionOrder[conditions[j].Type] || + // If same condition order, sort by last transition time (useful when computing aggregation of the same conditions from different objects) + (conditionOrder[conditions[i].Type] == conditionOrder[conditions[j].Type] && conditions[i].LastTransitionTime.Before(&conditions[j].LastTransitionTime)) + }) +} + +// splitConditionsByPriority split conditions in 3 groups: +// - conditions representing an issue. +// - conditions with status unknown. +// - conditions representing an info. +// NOTE: The order of conditions is preserved in each group. +func splitConditionsByPriority(conditions []ConditionWithOwnerInfo, negativePolarityConditionTypes sets.Set[string]) (issueConditions, unknownConditions, infoConditions []ConditionWithOwnerInfo) { + for _, condition := range conditions { + switch getPriority(condition.Condition, negativePolarityConditionTypes) { + case issueMergePriority: + issueConditions = append(issueConditions, condition) + case unknownMergePriority: + unknownConditions = append(unknownConditions, condition) + case infoMergePriority: + infoConditions = append(infoConditions, condition) + } + } + return issueConditions, unknownConditions, infoConditions +} + +// getPriority returns the merge priority for each condition. +// The default implementation identifies as: +// - issues: conditions with positive polarity (normal True) and status False or conditions with negative polarity (normal False) and status True. +// - unknown: conditions with status unknown. +// - info: conditions with positive polarity (normal True) and status True or conditions with negative polarity (normal False) and status False. +func getPriority(condition metav1.Condition, negativePolarityConditionTypes sets.Set[string]) mergePriority { + switch condition.Status { + case metav1.ConditionTrue: + if negativePolarityConditionTypes.Has(condition.Type) { + return issueMergePriority + } + return infoMergePriority + case metav1.ConditionFalse: + if negativePolarityConditionTypes.Has(condition.Type) { + return infoMergePriority + } + return issueMergePriority + case metav1.ConditionUnknown: + return unknownMergePriority + } + + // Note: this should never happen. In case, those conditions are considered like condition with unknown status. + return unknownMergePriority +} + +// aggregateMessages return messages for the aggregate operation. +func aggregateMessages(conditions []ConditionWithOwnerInfo, n *int, kind string, dropEmpty bool) (messages []string, other int) { + // create a map with all the messages and the list of objects reporting the same message. + messageObjMap := map[string][]string{} + for _, condition := range conditions { + if dropEmpty && condition.Message == "" { + continue + } + + m := fmt.Sprintf("(%s)", condition.Status) + if condition.Message != "" { + m += fmt.Sprintf(": %s", condition.Message) + } + if _, ok := messageObjMap[m]; !ok { + messageObjMap[m] = []string{} + } + messageObjMap[m] = append(messageObjMap[m], klog.KRef(condition.OwnerResource.Namespace, condition.OwnerResource.Name).String()) + } + + // compute the order of messages according to the number of objects reporting the same message. + // Note: The message text is used as a secondary criteria to sort messages with the same number of objects. + messageIndex := make([]string, 0, len(messageObjMap)) + for m := range messageObjMap { + messageIndex = append(messageIndex, m) + } + + sort.SliceStable(messageIndex, func(i, j int) bool { + return len(messageObjMap[messageIndex[i]]) > len(messageObjMap[messageIndex[j]]) || + (len(messageObjMap[messageIndex[i]]) == len(messageObjMap[messageIndex[j]]) && messageIndex[i] < messageIndex[j]) + }) + + // Pick the first n messages, decrement n. + // For each message, add up to three objects; if more add the number of the remaining objects with the same message. + // Count the number of objects reporting messages not included in the above. + // Note: we are showing up to three objects because usually control plane has 3 machines, and we want to show all issues + // to control plane machines if any, + for i := range len(messageIndex) { + if *n == 0 { + other += len(messageObjMap[messageIndex[i]]) + continue + } + + m := messageIndex[i] + allObjects := messageObjMap[messageIndex[i]] + if len(allObjects) <= 3 { + m += fmt.Sprintf(" from %s", strings.Join(allObjects, ", ")) + } else { + m += fmt.Sprintf(" from %s and %d other %s", strings.Join(allObjects[:3], ", "), len(allObjects)-3, kind) + } + + messages = append(messages, m) + *n-- + } + + return messages, other +} + +// getConditionsWithOwnerInfo return all the conditions from an object each one with the corresponding ConditionOwnerInfo. +func getConditionsWithOwnerInfo(obj runtime.Object) ([]ConditionWithOwnerInfo, error) { + ret := make([]ConditionWithOwnerInfo, 0, 10) + conditions, err := GetAll(obj) + if err != nil { + return nil, err + } + for _, condition := range conditions { + ret = append(ret, ConditionWithOwnerInfo{ + OwnerResource: getConditionOwnerInfo(obj), + Condition: condition, + }) + } + return ret, nil +} + +// getConditionOwnerInfo return the ConditionOwnerInfo for the given object. +func getConditionOwnerInfo(obj runtime.Object) ConditionOwnerInfo { + var apiVersion, kind, name, namespace string + kind = obj.GetObjectKind().GroupVersionKind().Kind + apiVersion = obj.GetObjectKind().GroupVersionKind().GroupVersion().String() + + if kind == "" { + t := reflect.TypeOf(obj) + if t.Kind() == reflect.Pointer { + kind = t.Elem().Name() + } + } + + if objMeta, ok := obj.(metav1.Object); ok { + name = objMeta.GetName() + namespace = objMeta.GetNamespace() + } + + return ConditionOwnerInfo{ + TypeMeta: metav1.TypeMeta{ + Kind: kind, + APIVersion: apiVersion, + }, + Namespace: namespace, + Name: name, + } +} diff --git a/util/conditions/experimental/merge_strategies_test.go b/util/conditions/experimental/merge_strategies_test.go new file mode 100644 index 000000000000..b541037a1253 --- /dev/null +++ b/util/conditions/experimental/merge_strategies_test.go @@ -0,0 +1,200 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" +) + +func TestAggregateMessages(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "obj1"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj2"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj3"}, Condition: metav1.Condition{Type: "A", Message: "Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj4"}, Condition: metav1.Condition{Type: "A", Message: "Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj5"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj6"}, Condition: metav1.Condition{Type: "A", Message: "Message-3", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj7"}, Condition: metav1.Condition{Type: "A", Message: "Message-4", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj8"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "obj9"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + } + + n := 3 + messages, others := aggregateMessages(conditions, &n, "objects", false) + + g.Expect(n).To(Equal(0)) + g.Expect(messages).To(Equal([]string{ + "(False): Message-1 from obj1, obj2, obj5 and 2 other objects", + "(False): Message-2 from obj3, obj4", + "(False): Message-3 from obj6", + })) + g.Expect(others).To(Equal(1)) +} + +func TestSortConditions(t *testing.T) { + g := NewWithT(t) + + t0 := metav1.Now() + t1 := metav1.Time{Time: t0.Add(10 * time.Minute)} + t2 := metav1.Time{Time: t0.Add(20 * time.Minute)} + + conditions := []ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + } + + orderedConditionTypes := []string{"A", "B", "!C"} + sortConditions(conditions, orderedConditionTypes) + + // Check conditions are sorted by orderedConditionTypes and by LastTransitionTime + + g.Expect(conditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown, LastTransitionTime: t2}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown, LastTransitionTime: t0}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse, LastTransitionTime: t1}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue, LastTransitionTime: t2}}, + })) +} + +func TestSplitConditionsByPriority(t *testing.T) { + g := NewWithT(t) + + conditions := []ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse}}, // issue + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue}}, // info + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse}}, // issue + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue}}, // issue + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown}}, // unknown + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown}}, // unknown + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown}}, // unknown + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue}}, // info + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse}}, // info + } + + issueConditions, unknownConditions, infoConditions := splitConditionsByPriority(conditions, sets.New[string]("!C")) + + // Check condition are grouped as expected and order is preserved. + + g.Expect(issueConditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionTrue}}, + })) + + g.Expect(unknownConditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionUnknown}}, + {OwnerResource: ConditionOwnerInfo{Name: "bar"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionUnknown}}, + })) + + g.Expect(infoConditions).To(Equal([]ConditionWithOwnerInfo{ + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "A", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Name: "foo"}, Condition: metav1.Condition{Type: "B", Status: metav1.ConditionTrue}}, + {OwnerResource: ConditionOwnerInfo{Name: "baz"}, Condition: metav1.Condition{Type: "!C", Status: metav1.ConditionFalse}}, + })) +} + +func TestGetPriority(t *testing.T) { + tests := []struct { + name string + condition metav1.Condition + negativePolarity bool + wantPriority mergePriority + }{ + { + name: "Issue (PositivePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionFalse}, + negativePolarity: false, + wantPriority: issueMergePriority, + }, + { + name: "Unknown (PositivePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionUnknown}, + negativePolarity: false, + wantPriority: unknownMergePriority, + }, + { + name: "Info (PositivePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionTrue}, + negativePolarity: false, + wantPriority: infoMergePriority, + }, + { + name: "NoStatus (PositivePolarity)", + condition: metav1.Condition{Type: "foo"}, + negativePolarity: false, + wantPriority: unknownMergePriority, + }, + { + name: "Issue (NegativePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionTrue}, + negativePolarity: true, + wantPriority: issueMergePriority, + }, + { + name: "Unknown (NegativePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionUnknown}, + negativePolarity: true, + wantPriority: unknownMergePriority, + }, + { + name: "Info (NegativePolarity)", + condition: metav1.Condition{Type: "foo", Status: metav1.ConditionFalse}, + negativePolarity: true, + wantPriority: infoMergePriority, + }, + { + name: "NoStatus (NegativePolarity)", + condition: metav1.Condition{Type: "foo"}, + negativePolarity: true, + wantPriority: unknownMergePriority, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + negativePolarityConditionTypes := sets.New[string]() + if tt.negativePolarity { + negativePolarityConditionTypes.Insert(tt.condition.Type) + } + gotPriority := getPriority(tt.condition, negativePolarityConditionTypes) + + g.Expect(gotPriority).To(Equal(tt.wantPriority)) + }) + } +} diff --git a/util/conditions/experimental/mirror.go b/util/conditions/experimental/mirror.go new file mode 100644 index 000000000000..9501a615b4c8 --- /dev/null +++ b/util/conditions/experimental/mirror.go @@ -0,0 +1,107 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/klog/v2" +) + +// NotYetReportedReason is set on missing conditions generated during mirror, aggregate or summary operations. +// Missing conditions are generated during the above operations when an expected condition does not exist on a object. +const NotYetReportedReason = "NotYetReported" + +// MirrorOption is some configuration that modifies options for a mirrorInto request. +type MirrorOption interface { + // ApplyToMirror applies this configuration to the given mirrorInto options. + ApplyToMirror(*MirrorOptions) +} + +// MirrorOptions allows to set options for the mirrorInto operation. +type MirrorOptions struct { + overrideType string +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *MirrorOptions) ApplyOptions(opts []MirrorOption) *MirrorOptions { + for _, opt := range opts { + opt.ApplyToMirror(o) + } + return o +} + +// NewMirrorCondition create a mirror of the given condition from obj; if the given condition does not exist in the source obj, +// a new condition with status Unknown, reason NotYetReported is created. +// +// By default, the Mirror condition has the same type of the source condition, but this can be changed by using +// the OverrideType option. +func NewMirrorCondition(obj runtime.Object, conditionType string, opts ...MirrorOption) (*metav1.Condition, error) { + mirrorOpt := &MirrorOptions{} + mirrorOpt.ApplyOptions(opts) + + conditions, err := GetAll(obj) + if err != nil { + return nil, err + } + conditionOwner := getConditionOwnerInfo(obj) + conditionOwnerString := strings.TrimSpace(fmt.Sprintf("%s %s", conditionOwner.Kind, klog.KRef(conditionOwner.Namespace, conditionOwner.Name))) + + var c *metav1.Condition + for i := range conditions { + if conditions[i].Type == conditionType { + c = &metav1.Condition{ + Type: conditions[i].Type, + Status: conditions[i].Status, + ObservedGeneration: conditions[i].ObservedGeneration, + LastTransitionTime: conditions[i].LastTransitionTime, + Reason: conditions[i].Reason, + Message: strings.TrimSpace(fmt.Sprintf("%s (from %s)", conditions[i].Message, conditionOwnerString)), + } + } + } + + if c == nil { + c = &metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionUnknown, + LastTransitionTime: metav1.Now(), + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported from %s", conditionType, conditionOwnerString), + } + } + + if mirrorOpt.overrideType != "" { + c.Type = mirrorOpt.overrideType + } + + return c, nil +} + +// SetMirrorCondition is a convenience method that calls NewMirrorCondition to create a mirror condition from the source object, +// and then calls Set to add the new condition to the target object. +func SetMirrorCondition(sourceObj, targetObj runtime.Object, conditionType string, opts ...MirrorOption) error { + mirrorCondition, err := NewMirrorCondition(sourceObj, conditionType, opts...) + if err != nil { + return err + } + return Set(targetObj, *mirrorCondition) +} diff --git a/util/conditions/experimental/mirror_test.go b/util/conditions/experimental/mirror_test.go new file mode 100644 index 000000000000..6c3827394bb2 --- /dev/null +++ b/util/conditions/experimental/mirror_test.go @@ -0,0 +1,102 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMirrorStatusCondition(t *testing.T) { + now := metav1.Now() + tests := []struct { + name string + conditions []metav1.Condition + conditionType string + options []MirrorOption + want metav1.Condition + }{ + { + name: "Mirror a condition", + conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good!", ObservedGeneration: 10, LastTransitionTime: now}, + }, + conditionType: "Ready", + options: []MirrorOption{}, + want: metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good! (from V1Beta2ResourceWithConditions default/SourceObject)", ObservedGeneration: 10, LastTransitionTime: now}, + }, + { + name: "Mirror a condition with override type", + conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good!", ObservedGeneration: 10, LastTransitionTime: now}, + }, + conditionType: "Ready", + options: []MirrorOption{OverrideType("SomethingReady")}, + want: metav1.Condition{Type: "SomethingReady", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good! (from V1Beta2ResourceWithConditions default/SourceObject)", ObservedGeneration: 10, LastTransitionTime: now}, + }, + { + name: "Mirror a condition with empty message", + conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "", ObservedGeneration: 10, LastTransitionTime: now}, + }, + conditionType: "Ready", + options: []MirrorOption{}, + want: metav1.Condition{Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "(from V1Beta2ResourceWithConditions default/SourceObject)", ObservedGeneration: 10, LastTransitionTime: now}, + }, + { + name: "Mirror a condition not yet reported", + conditions: []metav1.Condition{}, + conditionType: "Ready", + options: []MirrorOption{}, + want: metav1.Condition{Type: "Ready", Status: metav1.ConditionUnknown, Reason: NotYetReportedReason, Message: "Condition Ready not yet reported from V1Beta2ResourceWithConditions default/SourceObject", LastTransitionTime: now}, + }, + { + name: "Mirror a condition not yet reported with override type", + conditions: []metav1.Condition{}, + conditionType: "Ready", + options: []MirrorOption{OverrideType("SomethingReady")}, + want: metav1.Condition{Type: "SomethingReady", Status: metav1.ConditionUnknown, Reason: NotYetReportedReason, Message: "Condition Ready not yet reported from V1Beta2ResourceWithConditions default/SourceObject", LastTransitionTime: now}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &V1Beta2ResourceWithConditions{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "SourceObject", + }, + Status: struct{ Conditions []metav1.Condition }{ + Conditions: tt.conditions, + }, + } + + got, err := NewMirrorCondition(obj, tt.conditionType, tt.options...) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + + // TODO: think about how to improve this; the issue happens when the condition is not yet reported and one gets generated (using time.now) - Option is to make the time overridable + got.LastTransitionTime = tt.want.LastTransitionTime + + g.Expect(*got).To(Equal(tt.want)) + }) + } +} diff --git a/util/conditions/experimental/options.go b/util/conditions/experimental/options.go new file mode 100644 index 000000000000..f7a55211249d --- /dev/null +++ b/util/conditions/experimental/options.go @@ -0,0 +1,88 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +// ConditionFields defines the path where conditions are defined in Unstructured objects. +type ConditionFields []string + +// ApplyToSet applies this configuration to the given Set options. +func (f ConditionFields) ApplyToSet(opts *SetOptions) { + opts.conditionsFields = f +} + +// TODO: Think about using Less directly instead of defining a new option (might be only useful for the UX). + +// ConditionSortFunc defines the sort order when conditions are assigned to an object. +type ConditionSortFunc Less + +// ApplyToSet applies this configuration to the given Set options. +func (f ConditionSortFunc) ApplyToSet(opts *SetOptions) { + opts.less = Less(f) +} + +// OverrideType allows to override the type of new mirror or aggregate conditions. +type OverrideType string + +// ApplyToMirror applies this configuration to the given mirrorInto options. +func (t OverrideType) ApplyToMirror(opts *MirrorOptions) { + opts.overrideType = string(t) +} + +// ApplyToAggregate applies this configuration to the given aggregate options. +func (t OverrideType) ApplyToAggregate(opts *AggregateOptions) { + opts.overrideType = string(t) +} + +// ForConditionTypes allows to define the set of conditions in scope for a summary operation. +// Please note that condition types have an implicit order that can be used by the summary operation to determine relevance of the different conditions. +type ForConditionTypes []string + +// ApplyToSummary applies this configuration to the given summary options. +func (t ForConditionTypes) ApplyToSummary(opts *SummaryOptions) { + opts.conditionTypes = t +} + +// WithNegativeConditionTypes allows to define polarity for some of the conditions in scope for a summary operation. +type WithNegativeConditionTypes []string + +// ApplyToSummary applies this configuration to the given summary options. +func (t WithNegativeConditionTypes) ApplyToSummary(opts *SummaryOptions) { + opts.negativePolarityConditionTypes = t +} + +// WithMergeStrategy allows to define a custom merge strategy when creating new summary or aggregate conditions. +type WithMergeStrategy struct { + MergeStrategy +} + +// ApplyToSummary applies this configuration to the given summary options. +func (t WithMergeStrategy) ApplyToSummary(opts *SummaryOptions) { + opts.mergeStrategy = t +} + +// ApplyToAggregate applies this configuration to the given aggregate options. +func (t WithMergeStrategy) ApplyToAggregate(opts *AggregateOptions) { + opts.mergeStrategy = t +} + +// WithStepCounter adds a step counter message to new summary conditions. +type WithStepCounter bool + +// ApplyToSummary applies this configuration to the given summary options. +func (t WithStepCounter) ApplyToSummary(opts *SummaryOptions) { + opts.stepCounter = bool(t) +} diff --git a/util/conditions/experimental/setter.go b/util/conditions/experimental/setter.go new file mode 100644 index 000000000000..772158501b2e --- /dev/null +++ b/util/conditions/experimental/setter.go @@ -0,0 +1,207 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "fmt" + "reflect" + "sort" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" +) + +// SetOption is some configuration that modifies options for a Set request. +type SetOption interface { + // ApplyToSet applies this configuration to the given Set options. + ApplyToSet(option *SetOptions) +} + +// SetOptions allows to define options for the set operation. +type SetOptions struct { + conditionsFields []string + less Less +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *SetOptions) ApplyOptions(opts []SetOption) *SetOptions { + for _, opt := range opts { + opt.ApplyToSet(o) + } + return o +} + +// Set a condition on the given object. +// +// Set support adding conditions to objects at different stages of the transition to metav1.condition type: +// - Objects with metav1.condition in status.experimental conditions +// - Objects with metav1.condition in status.conditions +// +// In case the object is unstructured, it is required to provide the path where conditions are defined using +// the ConditionFields option (because it is not possible to infer where conditions are by looking at the UnstructuredContent only). +// +// Additionally, Set enforce the lexicographic condition order (Available and Ready fist, everything else in alphabetical order), +// but this can be changed by using the ConditionSortFunc option. +func Set(obj runtime.Object, condition metav1.Condition, opts ...SetOption) error { + conditions, err := GetAll(obj) + if err != nil { + return err + } + + if changed := meta.SetStatusCondition(&conditions, condition); !changed { + return nil + } + + return SetAll(obj, conditions, opts...) +} + +// SetAll the conditions on the given object. +// +// SetAll support adding conditions to objects at different stages of the transition to metav1.condition type: +// - Objects with metav1.condition in status.experimental conditions +// - Objects with metav1.condition in status.conditions +// +// In case the object is unstructured, it is required to provide the path where conditions are defined using +// the ConditionFields option (because it is not possible to infer where conditions are by looking at the UnstructuredContent only). +// +// Additionally, SetAll enforce the lexicographic condition order (Available and Ready fist, everything else in alphabetical order), +// but this can be changed by using the ConditionSortFunc option. +func SetAll(obj runtime.Object, conditions []metav1.Condition, opts ...SetOption) error { + setOpt := &SetOptions{ + // By default sort condition by lexicographicLess order (first available, then ready, then the other conditions if alphabetical order. + less: lexicographicLess, + } + setOpt.ApplyOptions(opts) + + // TODO: think about setting ObservedGeneration from obj + // TODO: think about setting LastTransition Time with reconcile time --> All the conditions will flit at the same time, but we loose correlation with logs + + if setOpt.less != nil { + sort.SliceStable(conditions, func(i, j int) bool { + return setOpt.less(conditions[i], conditions[j]) + }) + } + + switch obj.(type) { + case runtime.Unstructured: + return setToUnstructuredObject(obj, conditions, setOpt.conditionsFields) + default: + return setToTypedObject(obj, conditions) + } +} + +func setToUnstructuredObject(obj runtime.Object, conditions []metav1.Condition, conditionsFields []string) error { + if obj == nil { + return errors.New("cannot set conditions on a nil object") + } + + // NOTE: Given that we allow providers to migrate at different speed, it is required to support objects at the different stage of the transition from legacy conditions to metav1.conditions. + // In order to handle this with Unstructured, it is required to ask the path for the conditions field in the given object (it cannot be inferred). + // conditionsFields should be dropped when v1beta1 API are removed because new conditions will always be in Status.Conditions. + + if len(conditionsFields) == 0 { + return errors.New("while transition from legacy conditions types to metav1.conditions types is in progress, cannot set conditions on Unstructured object without conditionFields path being set") + } + + u, ok := obj.(runtime.Unstructured) + if !ok { + return errors.New("cannot call setToUnstructuredObject on a object which is not Unstructured") + } + + if !reflect.ValueOf(u).Elem().IsValid() { + return errors.New("obj cannot be nil") + } + + v := make([]interface{}, 0, len(conditions)) + for i := range conditions { + c, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&conditions[i]) + if err != nil { + return errors.Wrapf(err, "failed to convert conditions %s to Unstructured", conditions[i].Type) + } + v = append(v, c) + } + + if err := unstructured.SetNestedField(u.UnstructuredContent(), v, conditionsFields...); err != nil { + return errors.Wrap(err, "failed to set conditions into Unstructured") + } + return nil +} + +var metav1ConditionsType = reflect.TypeOf([]metav1.Condition{}) + +func setToTypedObject(obj runtime.Object, conditions []metav1.Condition) error { + if obj == nil { + return errors.New("cannot set conditions on a nil object") + } + + ptr := reflect.ValueOf(obj) + if ptr.Kind() != reflect.Pointer { + return errors.New("cannot set conditions on a object that is not a pointer") + } + + elem := ptr.Elem() + if !elem.IsValid() { + return errors.New("obj must be a valid value (non zero value of its type)") + } + + statusField := elem.FieldByName("Status") + if statusField == (reflect.Value{}) { + return errors.New("cannot set conditions on a object without a status field") + } + + // Set conditions. + // NOTE: Given that we allow providers to migrate at different speed, it is required to support objects at the different stage of the transition from legacy conditions to metav1.conditions. + // In order to handle this, first try to set Status.ExperimentalConditions, then Status.Conditions. + // The ExperimentalConditions branch should be dropped when v1beta1 API are removed. + + if conditionField := statusField.FieldByName("ExperimentalConditions"); conditionField != (reflect.Value{}) { + fmt.Println("Status.ExperimentalConditions is a", reflect.TypeOf(conditionField.Interface()).String()) + + if conditionField.Type() != metav1ConditionsType { + return errors.Errorf("cannot set conditions on Status.ExperimentalConditions field if it isn't %s: %s type detected", metav1ConditionsType.String(), reflect.TypeOf(conditionField.Interface()).String()) + } + + setToTypedField(conditionField, conditions) + return nil + } + + if conditionField := statusField.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + fmt.Println("Status.Conditions is a", reflect.TypeOf(conditionField.Interface()).String()) + + if conditionField.Type() != metav1ConditionsType { + return errors.Errorf("cannot set conditions on Status.Conditions field if it isn't %s: %s type detected", metav1ConditionsType.String(), reflect.TypeOf(conditionField.Interface()).String()) + } + + setToTypedField(conditionField, conditions) + return nil + } + + return errors.New("cannot set conditions on a object without Status.ExperimentalConditions or Status.Conditions") +} + +func setToTypedField(conditionField reflect.Value, conditions []metav1.Condition) { + n := len(conditions) + conditionField.Set(reflect.MakeSlice(conditionField.Type(), n, n)) + for i := range n { + itemField := conditionField.Index(i) + itemField.Set(reflect.ValueOf(conditions[i])) + } +} diff --git a/util/conditions/experimental/setter_test.go b/util/conditions/experimental/setter_test.go new file mode 100644 index 000000000000..c7cb98720a43 --- /dev/null +++ b/util/conditions/experimental/setter_test.go @@ -0,0 +1,299 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + . "github.com/onsi/gomega" + 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" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +func TestSetAll(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + + conditions := []metav1.Condition{ + { + Type: "fooCondition", + Status: metav1.ConditionTrue, + ObservedGeneration: 10, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + }, + } + + cloneConditions := func() []metav1.Condition { + ret := make([]metav1.Condition, len(conditions)) + copy(ret, conditions) + return ret + } + + t.Run("fails with nil", func(t *testing.T) { + g := NewWithT(t) + + conditions := cloneConditions() + err := SetAll(nil, conditions) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("fails for nil object", func(t *testing.T) { + g := NewWithT(t) + var foo *V1Beta1ResourceWithLegacyConditions + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).To(HaveOccurred()) + + var fooUnstructured *unstructured.Unstructured + + err = SetAll(fooUnstructured, conditions, ConditionFields{"status", "experimentalConditions"}) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("fails for object without status", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithoutStatus{} + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).To(HaveOccurred()) + + // TODO: think about how unstructured can detect status without statue (if we ever need it, because for unstructured the users explicitly define the ConditionFields) + }) + + t.Run("fails for object with status without conditions or experimental conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithStatusWithoutConditions{} + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + // TODO: think about how unstructured can detect status without conditions or experimental condition type (if we ever need it, because for unstructured the users explicitly define the ConditionFields) + }) + + t.Run("fails for object with wrong condition type", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongConditionType{} + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + // TODO: think about how unstructured can detect status with wrong conditions type (if we ever need it, because for unstructured the users explicitly define the ConditionFields) + }) + + t.Run("fails for object with wrong experimental condition type", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongExperimentalConditionType{} + + _, err := GetAll(foo) + g.Expect(err).To(HaveOccurred()) + + // TODO: think about how unstructured can detect status with wrong experimental conditions type (if we ever need it, because for unstructured the users explicitly define the ConditionFields) + }) + + t.Run("fails for Unstructured when ConditionFields are not provided", func(t *testing.T) { + g := NewWithT(t) + fooUnstructured := &unstructured.Unstructured{} + + conditions := cloneConditions() + err := SetAll(fooUnstructured, conditions) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("v1beta object with legacy conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta1ResourceWithLegacyConditions{ + Status: struct{ Conditions clusterv1.Conditions }{Conditions: nil}, + } + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).To(HaveOccurred()) // Can't set legacy conditions. + }) + + t.Run("v1beta1 object with both legacy and experimental conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta1ResourceWithLegacyAndExperimentalConditionsV1{ + Status: struct { + Conditions clusterv1.Conditions + ExperimentalConditions []metav1.Condition + }{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + ExperimentalConditions: nil, + }, + } + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(foo.Status.ExperimentalConditions).To(Equal(conditions), cmp.Diff(foo.Status.ExperimentalConditions, conditions)) + }) + + t.Run("v1beta1 object with both legacy and experimental conditions / Unstructured", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta1ResourceWithLegacyAndExperimentalConditionsV1{ + Status: struct { + Conditions clusterv1.Conditions + ExperimentalConditions []metav1.Condition + }{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + ExperimentalConditions: nil, + }, + } + + conditions := cloneConditions() + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + u := &unstructured.Unstructured{Object: fooUnstructured} + err = SetAll(u, conditions, ConditionFields{"status", "experimentalConditions"}) + g.Expect(err).NotTo(HaveOccurred()) + + fooFromUnstructured := &V1Beta1ResourceWithLegacyAndExperimentalConditionsV1{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), fooFromUnstructured) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := GetAll(fooFromUnstructured) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(conditions), cmp.Diff(got, conditions)) + }) + + t.Run("v1beta2 object with conditions and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta2ResourceWithConditionsAndBackwardCompatibleConditions{ + Status: struct { + Conditions []metav1.Condition + BackwardCompatibility struct{ Conditions clusterv1.Conditions } + }{ + Conditions: nil, + BackwardCompatibility: struct{ Conditions clusterv1.Conditions }{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + } + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(foo.Status.Conditions).To(Equal(conditions), cmp.Diff(foo.Status.Conditions, conditions)) + }) + + t.Run("v1beta2 object with conditions and backward compatible conditions / Unstructured", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta2ResourceWithConditionsAndBackwardCompatibleConditions{ + Status: struct { + Conditions []metav1.Condition + BackwardCompatibility struct{ Conditions clusterv1.Conditions } + }{ + Conditions: nil, + BackwardCompatibility: struct{ Conditions clusterv1.Conditions }{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + }, + }, + } + + conditions := cloneConditions() + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + u := &unstructured.Unstructured{Object: fooUnstructured} + err = SetAll(u, conditions, ConditionFields{"status", "conditions"}) + g.Expect(err).NotTo(HaveOccurred()) + + fooFromUnstructured := &V1Beta2ResourceWithConditionsAndBackwardCompatibleConditions{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), fooFromUnstructured) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := GetAll(fooFromUnstructured) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(conditions), cmp.Diff(got, conditions)) + }) + + t.Run("v1beta2 object with conditions (end state)", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta2ResourceWithConditions{ + Status: struct { + Conditions []metav1.Condition + }{ + Conditions: nil, + }, + } + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(foo.Status.Conditions).To(Equal(conditions), cmp.Diff(foo.Status.Conditions, conditions)) + }) + + t.Run("v1beta2 object with conditions (end state) / Unstructured", func(t *testing.T) { + g := NewWithT(t) + foo := &V1Beta2ResourceWithConditions{ + Status: struct { + Conditions []metav1.Condition + }{ + Conditions: nil, + }, + } + + conditions := cloneConditions() + fooUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(foo) + g.Expect(err).NotTo(HaveOccurred()) + + u := &unstructured.Unstructured{Object: fooUnstructured} + err = SetAll(u, conditions, ConditionFields{"status", "conditions"}) + g.Expect(err).NotTo(HaveOccurred()) + + fooFromUnstructured := &V1Beta2ResourceWithConditions{} + err = runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), fooFromUnstructured) + g.Expect(err).NotTo(HaveOccurred()) + + got, err := GetAll(fooFromUnstructured) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(Equal(conditions), cmp.Diff(got, conditions)) + }) +} diff --git a/util/conditions/experimental/sort.go b/util/conditions/experimental/sort.go new file mode 100644 index 000000000000..eb5aaa6d9b3e --- /dev/null +++ b/util/conditions/experimental/sort.go @@ -0,0 +1,38 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +const ( + // AvailableCondition documents availability for an object. + AvailableCondition = "Available" + + // ReadyCondition documents readiness for an object. + ReadyCondition = "Ready" +) + +// Less reports whether the i condition must sort before j condition. +type Less func(i, j metav1.Condition) bool + +// lexicographicLess returns true if a condition is less than another with regards to the +// to order of conditions designed for convenience of the consumer, i.e. kubectl get. +// According to this order the Available and the Ready condition always goes first, followed by all the other +// conditions sorted by Type. +func lexicographicLess(i, j metav1.Condition) bool { + return (i.Type == AvailableCondition || (i.Type == ReadyCondition || i.Type < j.Type) && j.Type != ReadyCondition) && j.Type != AvailableCondition +} diff --git a/util/conditions/experimental/sort_test.go b/util/conditions/experimental/sort_test.go new file mode 100644 index 000000000000..48c778fe9edb --- /dev/null +++ b/util/conditions/experimental/sort_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "sort" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestLexicographicLess(t *testing.T) { + g := NewWithT(t) + + conditions := []metav1.Condition{ + {Type: "B"}, + {Type: AvailableCondition}, + {Type: "A"}, + {Type: ReadyCondition}, + {Type: "C!"}, + } + + sort.Slice(conditions, func(i, j int) bool { + return lexicographicLess(conditions[i], conditions[j]) + }) + + g.Expect(conditions).To(Equal([]metav1.Condition{ + {Type: AvailableCondition}, + {Type: ReadyCondition}, + {Type: "A"}, + {Type: "B"}, + {Type: "C!"}, + })) +} diff --git a/util/conditions/experimental/summary.go b/util/conditions/experimental/summary.go new file mode 100644 index 000000000000..bac71fe9517f --- /dev/null +++ b/util/conditions/experimental/summary.go @@ -0,0 +1,129 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" +) + +// SummaryOption is some configuration that modifies options for a summary request. +type SummaryOption interface { + // ApplyToSummary applies this configuration to the given summary options. + ApplyToSummary(*SummaryOptions) +} + +// SummaryOptions allows to set options for the summary operation. +type SummaryOptions struct { + mergeStrategy MergeStrategy + conditionTypes []string + negativePolarityConditionTypes []string + stepCounter bool +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *SummaryOptions) ApplyOptions(opts []SummaryOption) *SummaryOptions { + for _, opt := range opts { + opt.ApplyToSummary(o) + } + return o +} + +// NewSummaryCondition creates a new condition by summarizing a set of conditions from an object. +// If any of the condition in scope does not exist in the source object, missing conditions are considered Unknown, reason NotYetReported. +// +// By default, the Aggregate condition has the same type of the source condition, but this can be changed by using +// the OverrideType option. +// +// Additionally, it is possible to inject custom merge strategies using the WithMergeStrategy option or +// to add a step counter to the generated message by using the WithStepCounter option. +func NewSummaryCondition(obj runtime.Object, conditionType string, opts ...SummaryOption) (*metav1.Condition, error) { + summarizeOpt := &SummaryOptions{ + mergeStrategy: newDefaultMergeStrategy(), + } + summarizeOpt.ApplyOptions(opts) + + conditions, err := getConditionsWithOwnerInfo(obj) + if err != nil { + return nil, err + } + + expectedConditionTypes := sets.New[string](summarizeOpt.conditionTypes...) + existingConditionTypes := sets.New[string]() + + // Drops all the conditions not in scope for the merge operation + conditionsInScope := make([]ConditionWithOwnerInfo, 0, len(expectedConditionTypes)) + for _, condition := range conditions { + if !expectedConditionTypes.Has(condition.Type) { + continue + } + conditionsInScope = append(conditionsInScope, condition) + existingConditionTypes.Insert(condition.Type) + } + + // Add the expected conditions which do net exists, so we are compliant with K8s guidelines + // (all missing conditions should be considered unknown). + // TODO: consider if we want to allow exception to this rule. e.g it is ok that HealthCheckSucceeded is missing. + + diff := expectedConditionTypes.Difference(existingConditionTypes).UnsortedList() + if len(diff) > 0 { + conditionOwner := getConditionOwnerInfo(obj) + + for _, conditionType := range diff { + conditionsInScope = append(conditionsInScope, ConditionWithOwnerInfo{ + OwnerResource: conditionOwner, + Condition: metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported", conditionType), + }, + }) + } + } + + status, reason, message, err := summarizeOpt.mergeStrategy.Merge( + conditionsInScope, + summarizeOpt.conditionTypes, + sets.New[string](summarizeOpt.negativePolarityConditionTypes...), + summarizeOpt.stepCounter, + ) + if err != nil { + return nil, err + } + + return &metav1.Condition{ + Type: conditionType, + Status: status, + Reason: reason, + Message: message, + }, err +} + +// SetSummaryCondition is a convenience method that calls NewSummaryCondition to create a summary condition from the source object, +// and then calls Set to add the new condition to the target object. +func SetSummaryCondition(sourceObj, targetObj runtime.Object, conditionType string, opts ...MirrorOption) error { + mirrorCondition, err := NewMirrorCondition(sourceObj, conditionType, opts...) + if err != nil { + return err + } + return Set(targetObj, *mirrorCondition) +} diff --git a/util/conditions/experimental/summary_test.go b/util/conditions/experimental/summary_test.go new file mode 100644 index 000000000000..232f588a999c --- /dev/null +++ b/util/conditions/experimental/summary_test.go @@ -0,0 +1,202 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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 experimental + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestSummary(t *testing.T) { + tests := []struct { + name string + conditions []metav1.Condition + conditionType string + options []SummaryOption + want *metav1.Condition + }{ + { + name: "One issue", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C", Message: "Message-!C"}, // issue + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: "Reason-!C", // Picking the reason from the only existing issue + Message: "!C (True): Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "More than one issue", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionFalse, Reason: "Reason-B", Message: "Message-B"}, // issue + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C", Message: "Message-!C"}, // issue + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there are many issues + Reason: ManyIssuesReason, // Using a generic reason + Message: "B (False): Message-B; !C (True): Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "More than one issue and one unknown condition", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionFalse, Reason: "Reason-B", Message: "Message-B"}, // issue + {Type: "A", Status: metav1.ConditionUnknown, Reason: "Reason-A", Message: "Message-A"}, // unknown + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C", Message: "Message-!C"}, // issue + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there are many issues + Reason: ManyIssuesReason, // Using a generic reason + Message: "B (False): Message-B; !C (True): Message-!C; A (Unknown): Message-A", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "One unknown (no issues)", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionUnknown, Reason: "Reason-!C", Message: "Message-!C"}, // unknown + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there is one unknown + Reason: "Reason-!C", // Picking the reason from the only existing unknown + Message: "!C (Unknown): Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "More than one unknown (no issues)", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionUnknown, Reason: "Reason-B", Message: "Message-B"}, // unknown + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + {Type: "!C", Status: metav1.ConditionUnknown, Reason: "Reason-!C", Message: "Message-!C"}, // unknown + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there are many unknown + Reason: ManyUnknownsReason, // Using a generic reason + Message: "B (Unknown): Message-B; !C (Unknown): Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + + // TODO: Think about how to test One info (no issues, no unknown): If I use only one ConditionTypes is considered Aggregate, If I use more, it is not One info (no issues, no unknown) + + { + name: "More than one info (no issues, no unknown)", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: ""}, // info + {Type: "!C", Status: metav1.ConditionFalse, Reason: "Reason-!C", Message: "Message-!C"}, // info + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}, WithMergeStrategy{newDefaultMergeStrategy()}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionTrue, // True because there are many info + Reason: ManyInfoReason, // Using a generic reason + Message: "B (True): Message-B; !C (False): Message-!C", // messages from all the info conditions (empty messages are dropped) + }, + }, + { + name: "Default missing conditions to unknown", + conditions: []metav1.Condition{ + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: "Message-A"}, // info + // B and !C missing + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}}, // B and !C are required! + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there more than one unknown + Reason: ManyUnknownsReason, // Picking the reason from the only existing issue + Message: "B (Unknown): Condition B not yet reported; !C (Unknown): Condition !C not yet reported", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Ignore conditions not in scope", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: ""}, // info + {Type: "!C", Status: metav1.ConditionUnknown, Reason: "Reason-!C", Message: "Message-!C"}, // unknown + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B"}}, // C not in scope + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionTrue, // True because there are many info + Reason: ManyInfoReason, // Using a generic reason + Message: "B (True): Message-B", // messages from all the info conditions (empty messages are dropped) + }, + }, + { + name: "With stepCounter", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B", Message: "Message-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A", Message: ""}, // info + {Type: "!C", Status: metav1.ConditionUnknown, Reason: "Reason-!C", Message: "Message-!C"}, // unknown + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, WithNegativeConditionTypes{"!C"}, WithStepCounter(true)}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there is one unknown + Reason: "Reason-!C", // Picking the reason from the only existing unknown + Message: "2 of 3 completed; !C (Unknown): Message-!C", // step counter + messages from all the issues & unknown conditions (info dropped) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &V1Beta2ResourceWithConditions{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "SourceObject", + }, + Status: struct{ Conditions []metav1.Condition }{ + Conditions: tt.conditions, + }, + } + + got, err := NewSummaryCondition(obj, tt.conditionType, tt.options...) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(got).To(Equal(tt.want)) + }) + } +}