diff --git a/Makefile b/Makefile index 5371d5b48d96..475e0ade018c 100644 --- a/Makefile +++ b/Makefile @@ -305,6 +305,10 @@ generate-manifests-core: $(CONTROLLER_GEN) $(KUSTOMIZE) ## Generate manifests e. crd:crdVersions=v1 \ output:crd:dir=./cmd/clusterctl/config/crd/bases $(KUSTOMIZE) build $(CLUSTERCTL_MANIFEST_DIR)/crd > $(CLUSTERCTL_MANIFEST_DIR)/manifest/clusterctl-api.yaml + $(CONTROLLER_GEN) \ + paths=./internal/test/builder/... \ + crd:crdVersions=v1 \ + output:crd:dir=./internal/test/builder/crd .PHONY: generate-manifests-kubeadm-bootstrap generate-manifests-kubeadm-bootstrap: $(CONTROLLER_GEN) ## Generate manifests e.g. CRD, RBAC etc. for kubeadm bootstrap diff --git a/internal/test/builder/crd/test.cluster.x-k8s.io_phase0obj.yaml b/internal/test/builder/crd/test.cluster.x-k8s.io_phase0obj.yaml new file mode 100644 index 000000000000..1be851749ef3 --- /dev/null +++ b/internal/test/builder/crd/test.cluster.x-k8s.io_phase0obj.yaml @@ -0,0 +1,103 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: phase0obj.test.cluster.x-k8s.io +spec: + group: test.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase0Obj + listKind: Phase0ObjList + plural: phase0obj + singular: phase0obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase0Obj defines an object with clusterv1.Conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase0ObjSpec defines the spec of a Phase0Obj. + properties: + foo: + type: string + type: object + status: + description: Phase0ObjStatus defines the status of a Phase0Obj. + properties: + bar: + type: string + conditions: + description: Conditions provide observations of the operational state + of a Cluster API resource. + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + A human readable message indicating details about the transition. + This field may be empty. + type: string + reason: + description: |- + The reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may be empty. + type: string + severity: + description: |- + Severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/test/builder/crd/test.cluster.x-k8s.io_phase1obj.yaml b/internal/test/builder/crd/test.cluster.x-k8s.io_phase1obj.yaml new file mode 100644 index 000000000000..a19fbcb5aced --- /dev/null +++ b/internal/test/builder/crd/test.cluster.x-k8s.io_phase1obj.yaml @@ -0,0 +1,169 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: phase1obj.test.cluster.x-k8s.io +spec: + group: test.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase1Obj + listKind: Phase1ObjList + plural: phase1obj + singular: phase1obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase1Obj defines an object with conditions and experimental + conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase1ObjSpec defines the spec of a Phase1Obj. + properties: + foo: + type: string + type: object + status: + description: Phase1ObjStatus defines the status of a Phase1Obj. + properties: + bar: + type: string + conditions: + description: Conditions provide observations of the operational state + of a Cluster API resource. + items: + description: Condition defines an observation of a Cluster API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + A human readable message indicating details about the transition. + This field may be empty. + type: string + reason: + description: |- + The reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may be empty. + type: string + severity: + description: |- + Severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: |- + Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + v1beta2: + description: Phase1ObjStatusV1Beta2 defines the status.V1Beta2 of + a Phase1Obj. + properties: + conditions: + items: + description: Condition contains details for one aspect of the + current state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, + Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/test/builder/crd/test.cluster.x-k8s.io_phase2obj.yaml b/internal/test/builder/crd/test.cluster.x-k8s.io_phase2obj.yaml new file mode 100644 index 000000000000..250fe4ed1cb5 --- /dev/null +++ b/internal/test/builder/crd/test.cluster.x-k8s.io_phase2obj.yaml @@ -0,0 +1,174 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: phase2obj.test.cluster.x-k8s.io +spec: + group: test.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase2Obj + listKind: Phase2ObjList + plural: phase2obj + singular: phase2obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase2Obj defines an object with conditions and back compatibility + conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase2ObjSpec defines the spec of a Phase2Obj. + properties: + foo: + type: string + type: object + status: + description: Phase2ObjStatus defines the status of a Phase2Obj. + properties: + bar: + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + deprecated: + description: Phase2ObjStatusDeprecated defines the status.Deprecated + of a Phase2Obj. + properties: + v1beta1: + description: Phase2ObjStatusDeprecatedV1Beta1 defines the status.Deprecated.V1Beta2 + of a Phase2Obj. + properties: + conditions: + description: Conditions provide observations of the operational + state of a Cluster API resource. + items: + description: Condition defines an observation of a Cluster + API resource operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + A human readable message indicating details about the transition. + This field may be empty. + type: string + reason: + description: |- + The reason for the condition's last transition in CamelCase. + The specific API may choose whether or not this field is considered a guaranteed API. + This field may be empty. + type: string + severity: + description: |- + Severity provides an explicit classification of Reason code, so the users or machines can immediately + understand the current situation and act accordingly. + The Severity field MUST be set only when Status=False. + type: string + status: + description: Status of the condition, one of True, False, + Unknown. + type: string + type: + description: |- + Type of condition in CamelCase or in foo.example.com/CamelCase. + Many .condition.type values are consistent across resources like Available, but because arbitrary conditions + can be useful (see .node.status.conditions), the ability to deconflict is important. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + type: object + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/test/builder/crd/test.cluster.x-k8s.io_phase3obj.yaml b/internal/test/builder/crd/test.cluster.x-k8s.io_phase3obj.yaml new file mode 100644 index 000000000000..ee6029a64026 --- /dev/null +++ b/internal/test/builder/crd/test.cluster.x-k8s.io_phase3obj.yaml @@ -0,0 +1,116 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: phase3obj.test.cluster.x-k8s.io +spec: + group: test.cluster.x-k8s.io + names: + categories: + - cluster-api + kind: Phase3Obj + listKind: Phase3ObjList + plural: phase3obj + singular: phase3obj + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: Phase3Obj defines an object with metav1.conditions. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: Phase3ObjSpec defines the spec of a Phase3Obj. + properties: + foo: + type: string + type: object + status: + description: Phase3ObjStatus defines the status of a Phase3Obj. + properties: + bar: + type: string + conditions: + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/test/builder/doc.go b/internal/test/builder/doc.go index 6c346d1ef4da..0c6988cbd434 100644 --- a/internal/test/builder/doc.go +++ b/internal/test/builder/doc.go @@ -16,4 +16,6 @@ limitations under the License. // Package builder implements builder and CRDs for creating API objects for testing. // +kubebuilder:object:generate=true +// +groupName=test.cluster.x-k8s.io +// +versionName=v1alpha1 package builder diff --git a/internal/test/builder/v1beta2_transition.go b/internal/test/builder/v1beta2_transition.go new file mode 100644 index 000000000000..1c838b8653ec --- /dev/null +++ b/internal/test/builder/v1beta2_transition.go @@ -0,0 +1,253 @@ +/* +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 builder + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// This files provide types to validate transition from clusterv1.Conditions in v1Beta1 API to the metav1.Conditions in the v1Beta2 API. +// Please refer to util/conditions/v1beta2/doc.go for more context. + +var ( + // TestGroupVersion is group version used for test CRDs used for validating the v1beta2 transition. + TestGroupVersion = schema.GroupVersion{Group: "test.cluster.x-k8s.io", Version: "v1alpha1"} + + // schemeBuilder is used to add go types to the GroupVersionKind scheme. + schemeBuilder = runtime.NewSchemeBuilder(addTransitionV1beta2Types) + + // AddTransitionV1Beta2ToScheme adds the types for validating the transition to v1Beta2 in this group-version to the given scheme. + AddTransitionV1Beta2ToScheme = schemeBuilder.AddToScheme +) + +func addTransitionV1beta2Types(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(TestGroupVersion, + &Phase0Obj{}, &Phase0ObjList{}, + &Phase1Obj{}, &Phase1ObjList{}, + &Phase2Obj{}, &Phase2ObjList{}, + &Phase3Obj{}, &Phase3ObjList{}, + ) + metav1.AddToGroupVersion(scheme, TestGroupVersion) + return nil +} + +// Phase0ObjList is a list of Phase0Obj. +// +kubebuilder:object:root=true +type Phase0ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase0Obj `json:"items"` +} + +// Phase0Obj defines an object with clusterv1.Conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase0obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase0Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase0ObjSpec `json:"spec,omitempty"` + Status Phase0ObjStatus `json:"status,omitempty"` +} + +// Phase0ObjSpec defines the spec of a Phase0Obj. +type Phase0ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase0ObjStatus defines the status of a Phase0Obj. +type Phase0ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` +} + +// GetConditions returns the set of conditions for this object. +func (o *Phase0Obj) GetConditions() clusterv1.Conditions { + return o.Status.Conditions +} + +// SetConditions sets the conditions on this object. +func (o *Phase0Obj) SetConditions(conditions clusterv1.Conditions) { + o.Status.Conditions = conditions +} + +// Phase1ObjList is a list of Phase1Obj. +// +kubebuilder:object:root=true +type Phase1ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase1Obj `json:"items"` +} + +// Phase1Obj defines an object with conditions and experimental conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase1obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase1Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase1ObjSpec `json:"spec,omitempty"` + Status Phase1ObjStatus `json:"status,omitempty"` +} + +// Phase1ObjSpec defines the spec of a Phase1Obj. +type Phase1ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase1ObjStatus defines the status of a Phase1Obj. +type Phase1ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` + + // +optional + V1Beta2 *Phase1ObjStatusV1Beta2 `json:"v1beta2,omitempty"` +} + +// Phase1ObjStatusV1Beta2 defines the status.V1Beta2 of a Phase1Obj. +type Phase1ObjStatusV1Beta2 struct { + + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// GetConditions returns the set of conditions for this object. +func (o *Phase1Obj) GetConditions() clusterv1.Conditions { + return o.Status.Conditions +} + +// SetConditions sets the conditions on this object. +func (o *Phase1Obj) SetConditions(conditions clusterv1.Conditions) { + o.Status.Conditions = conditions +} + +// Phase2ObjList is a list of Phase2Obj. +// +kubebuilder:object:root=true +type Phase2ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase2Obj `json:"items"` +} + +// Phase2Obj defines an object with conditions and back compatibility conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase2obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase2Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase2ObjSpec `json:"spec,omitempty"` + Status Phase2ObjStatus `json:"status,omitempty"` +} + +// Phase2ObjSpec defines the spec of a Phase2Obj. +type Phase2ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase2ObjStatus defines the status of a Phase2Obj. +type Phase2ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // +optional + Deprecated *Phase2ObjStatusDeprecated `json:"deprecated,omitempty"` +} + +// Phase2ObjStatusDeprecated defines the status.Deprecated of a Phase2Obj. +type Phase2ObjStatusDeprecated struct { + + // +optional + V1Beta1 *Phase2ObjStatusDeprecatedV1Beta1 `json:"v1beta1,omitempty"` +} + +// Phase2ObjStatusDeprecatedV1Beta1 defines the status.Deprecated.V1Beta2 of a Phase2Obj. +type Phase2ObjStatusDeprecatedV1Beta1 struct { + + // +optional + Conditions clusterv1.Conditions `json:"conditions,omitempty"` +} + +// GetConditions returns the set of conditions for this object. +func (o *Phase2Obj) GetConditions() clusterv1.Conditions { + return o.Status.Deprecated.V1Beta1.Conditions +} + +// SetConditions sets the conditions on this object. +func (o *Phase2Obj) SetConditions(conditions clusterv1.Conditions) { + o.Status.Deprecated.V1Beta1.Conditions = conditions +} + +// Phase3ObjList is a list of Phase3Obj. +// +kubebuilder:object:root=true +type Phase3ObjList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Phase3Obj `json:"items"` +} + +// Phase3Obj defines an object with metav1.conditions. +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=phase3obj,scope=Namespaced,categories=cluster-api +// +kubebuilder:subresource:status +// +kubebuilder:storageversion +type Phase3Obj struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec Phase3ObjSpec `json:"spec,omitempty"` + Status Phase3ObjStatus `json:"status,omitempty"` +} + +// Phase3ObjSpec defines the spec of a Phase3Obj. +type Phase3ObjSpec struct { + // +optional + Foo string `json:"foo,omitempty"` +} + +// Phase3ObjStatus defines the status of a Phase3Obj. +type Phase3ObjStatus struct { + // +optional + Bar string `json:"bar,omitempty"` + + // +optional + // +listType=map + // +listMapKey=type + Conditions []metav1.Condition `json:"conditions,omitempty"` +} diff --git a/internal/test/builder/zz_generated.deepcopy.go b/internal/test/builder/zz_generated.deepcopy.go index fc215627ce28..599a358ee3fb 100644 --- a/internal/test/builder/zz_generated.deepcopy.go +++ b/internal/test/builder/zz_generated.deepcopy.go @@ -22,6 +22,7 @@ package builder import ( "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/util/intstr" "sigs.k8s.io/cluster-api/api/v1beta1" apiv1beta1 "sigs.k8s.io/cluster-api/exp/api/v1beta1" @@ -844,6 +845,464 @@ func (in *NodeBuilder) DeepCopy() *NodeBuilder { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0Obj) DeepCopyInto(out *Phase0Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0Obj. +func (in *Phase0Obj) DeepCopy() *Phase0Obj { + if in == nil { + return nil + } + out := new(Phase0Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase0Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0ObjList) DeepCopyInto(out *Phase0ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase0Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0ObjList. +func (in *Phase0ObjList) DeepCopy() *Phase0ObjList { + if in == nil { + return nil + } + out := new(Phase0ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase0ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0ObjSpec) DeepCopyInto(out *Phase0ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0ObjSpec. +func (in *Phase0ObjSpec) DeepCopy() *Phase0ObjSpec { + if in == nil { + return nil + } + out := new(Phase0ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase0ObjStatus) DeepCopyInto(out *Phase0ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase0ObjStatus. +func (in *Phase0ObjStatus) DeepCopy() *Phase0ObjStatus { + if in == nil { + return nil + } + out := new(Phase0ObjStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1Obj) DeepCopyInto(out *Phase1Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1Obj. +func (in *Phase1Obj) DeepCopy() *Phase1Obj { + if in == nil { + return nil + } + out := new(Phase1Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase1Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjList) DeepCopyInto(out *Phase1ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase1Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjList. +func (in *Phase1ObjList) DeepCopy() *Phase1ObjList { + if in == nil { + return nil + } + out := new(Phase1ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase1ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjSpec) DeepCopyInto(out *Phase1ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjSpec. +func (in *Phase1ObjSpec) DeepCopy() *Phase1ObjSpec { + if in == nil { + return nil + } + out := new(Phase1ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjStatus) DeepCopyInto(out *Phase1ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.V1Beta2 != nil { + in, out := &in.V1Beta2, &out.V1Beta2 + *out = new(Phase1ObjStatusV1Beta2) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjStatus. +func (in *Phase1ObjStatus) DeepCopy() *Phase1ObjStatus { + if in == nil { + return nil + } + out := new(Phase1ObjStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase1ObjStatusV1Beta2) DeepCopyInto(out *Phase1ObjStatusV1Beta2) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase1ObjStatusV1Beta2. +func (in *Phase1ObjStatusV1Beta2) DeepCopy() *Phase1ObjStatusV1Beta2 { + if in == nil { + return nil + } + out := new(Phase1ObjStatusV1Beta2) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2Obj) DeepCopyInto(out *Phase2Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2Obj. +func (in *Phase2Obj) DeepCopy() *Phase2Obj { + if in == nil { + return nil + } + out := new(Phase2Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase2Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjList) DeepCopyInto(out *Phase2ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase2Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjList. +func (in *Phase2ObjList) DeepCopy() *Phase2ObjList { + if in == nil { + return nil + } + out := new(Phase2ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase2ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjSpec) DeepCopyInto(out *Phase2ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjSpec. +func (in *Phase2ObjSpec) DeepCopy() *Phase2ObjSpec { + if in == nil { + return nil + } + out := new(Phase2ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjStatus) DeepCopyInto(out *Phase2ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Deprecated != nil { + in, out := &in.Deprecated, &out.Deprecated + *out = new(Phase2ObjStatusDeprecated) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjStatus. +func (in *Phase2ObjStatus) DeepCopy() *Phase2ObjStatus { + if in == nil { + return nil + } + out := new(Phase2ObjStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjStatusDeprecated) DeepCopyInto(out *Phase2ObjStatusDeprecated) { + *out = *in + if in.V1Beta1 != nil { + in, out := &in.V1Beta1, &out.V1Beta1 + *out = new(Phase2ObjStatusDeprecatedV1Beta1) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjStatusDeprecated. +func (in *Phase2ObjStatusDeprecated) DeepCopy() *Phase2ObjStatusDeprecated { + if in == nil { + return nil + } + out := new(Phase2ObjStatusDeprecated) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase2ObjStatusDeprecatedV1Beta1) DeepCopyInto(out *Phase2ObjStatusDeprecatedV1Beta1) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(v1beta1.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase2ObjStatusDeprecatedV1Beta1. +func (in *Phase2ObjStatusDeprecatedV1Beta1) DeepCopy() *Phase2ObjStatusDeprecatedV1Beta1 { + if in == nil { + return nil + } + out := new(Phase2ObjStatusDeprecatedV1Beta1) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3Obj) DeepCopyInto(out *Phase3Obj) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3Obj. +func (in *Phase3Obj) DeepCopy() *Phase3Obj { + if in == nil { + return nil + } + out := new(Phase3Obj) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase3Obj) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3ObjList) DeepCopyInto(out *Phase3ObjList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Phase3Obj, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3ObjList. +func (in *Phase3ObjList) DeepCopy() *Phase3ObjList { + if in == nil { + return nil + } + out := new(Phase3ObjList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Phase3ObjList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3ObjSpec) DeepCopyInto(out *Phase3ObjSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3ObjSpec. +func (in *Phase3ObjSpec) DeepCopy() *Phase3ObjSpec { + if in == nil { + return nil + } + out := new(Phase3ObjSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Phase3ObjStatus) DeepCopyInto(out *Phase3ObjStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Phase3ObjStatus. +func (in *Phase3ObjStatus) DeepCopy() *Phase3ObjStatus { + if in == nil { + return nil + } + out := new(Phase3ObjStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TestBootstrapConfigBuilder) DeepCopyInto(out *TestBootstrapConfigBuilder) { *out = *in diff --git a/internal/test/envtest/environment.go b/internal/test/envtest/environment.go index 2727ff1f7b68..0151e1ecad1a 100644 --- a/internal/test/envtest/environment.go +++ b/internal/test/envtest/environment.go @@ -90,6 +90,7 @@ func init() { utilruntime.Must(admissionv1.AddToScheme(scheme.Scheme)) utilruntime.Must(runtimev1.AddToScheme(scheme.Scheme)) utilruntime.Must(ipamv1.AddToScheme(scheme.Scheme)) + utilruntime.Must(builder.AddTransitionV1Beta2ToScheme(scheme.Scheme)) } // RunInput is the input for Run. @@ -206,6 +207,7 @@ func newEnvironment(uncachedObjs ...client.Object) *Environment { filepath.Join(root, "config", "crd", "bases"), filepath.Join(root, "controlplane", "kubeadm", "config", "crd", "bases"), filepath.Join(root, "bootstrap", "kubeadm", "config", "crd", "bases"), + filepath.Join(root, "internal", "test", "builder", "crd"), }, CRDs: []*apiextensionsv1.CustomResourceDefinition{ builder.GenericBootstrapConfigCRD.DeepCopy(), diff --git a/util/conditions/v1beta2/aggregate.go b/util/conditions/v1beta2/aggregate.go new file mode 100644 index 000000000000..d01e959ba103 --- /dev/null +++ b/util/conditions/v1beta2/aggregate.go @@ -0,0 +1,133 @@ +/* +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 v1beta2 + +import ( + "fmt" + + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// AggregateOption is some configuration that modifies options for a aggregate call. +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 + targetConditionType 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; the given condition must have positive polarity; +// if the given condition does not exist in one of the source objects, 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 TargetConditionType option. +// +// Additionally, it is possible to inject custom merge strategies using the CustomMergeStrategy option. +func NewAggregateCondition(sourceObjs []runtime.Object, sourceConditionType string, opts ...AggregateOption) (*metav1.Condition, error) { + if len(sourceObjs) == 0 { + return nil, errors.New("sourceObjs can't be empty") + } + + aggregateOpt := &AggregateOptions{ + mergeStrategy: newDefaultMergeStrategy(), + targetConditionType: sourceConditionType, + } + aggregateOpt.ApplyOptions(opts) + + conditionsInScope := make([]ConditionWithOwnerInfo, 0, len(sourceObjs)) + for _, obj := range sourceObjs { + conditions, err := getConditionsWithOwnerInfo(obj) + if err != nil { + // Note: considering all sourceObjs are usually of the same type (and thus getConditionsWithOwnerInfo will either pass or fail for all sourceObjs), we are returning at the first error. + // This also avoid to implement fancy error aggregation, which is required to manage a potentially high number of sourceObjs/errors. + return nil, err + } + + // Drops all the conditions not in scope for the merge operation + hasConditionType := false + for _, condition := range conditions { + if condition.Type != sourceConditionType { + continue + } + conditionsInScope = append(conditionsInScope, condition) + hasConditionType = true + break + } + + // 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: sourceConditionType, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported", sourceConditionType), + // NOTE: LastTransitionTime and ObservedGeneration are not relevant for merge. + }, + }) + } + } + + status, reason, message, err := aggregateOpt.mergeStrategy.Merge( + conditionsInScope, + []string{sourceConditionType}, + nil, // negative conditions + false, // step counter + ) + if err != nil { + return nil, err + } + + c := &metav1.Condition{ + Type: aggregateOpt.targetConditionType, + Status: status, + Reason: reason, + Message: message, + // NOTE: LastTransitionTime and ObservedGeneration will be set when this condition is added to an object by calling Set. + } + + 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 { + aggregateCondition, err := NewAggregateCondition(sourceObjs, conditionType, opts...) + if err != nil { + return err + } + return Set(targetObj, *aggregateCondition) +} diff --git a/util/conditions/v1beta2/aggregate_test.go b/util/conditions/v1beta2/aggregate_test.go new file mode 100644 index 000000000000..203659399f0d --- /dev/null +++ b/util/conditions/v1beta2/aggregate_test.go @@ -0,0 +1,279 @@ +/* +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 v1beta2 + +import ( + "fmt" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "sigs.k8s.io/cluster-api/internal/test/builder" +) + +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"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + 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: "Message-1 from Phase3Obj obj0", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "One issue with target type", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj1 + }, + conditionType: AvailableCondition, + options: []AggregateOption{TargetConditionType("SomethingAvailable")}, + want: &metav1.Condition{ + Type: "SomethingAvailable", + Status: metav1.ConditionFalse, // False because there is one issue + Reason: "Reason-1", // Picking the reason from the only existing issue + Message: "Message-1 from Phase3Obj obj0", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Same issue from up to three objects", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{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.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj3 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: MultipleIssuesReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Objs obj0, obj1, obj2", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Same issue from more than three objects", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {{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-B", Message: "Message-1"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Message: "Message-99"}}, // obj5 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: MultipleIssuesReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Objs obj0, obj1, obj2 and 2 more", // 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"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // 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-3", Message: "Message-3"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: MultipleIssuesReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Objs obj0, obj3, obj4; Message-2 from Phase3Objs obj1, obj2; Message-3 from Phase3Obj 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"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-4", Message: "Message-4"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-5", Message: "Message-5"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-3", Message: "Message-3"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: MultipleIssuesReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Objs obj0, obj4; Message-2 from Phase3Obj obj1; Message-3 from Phase3Obj obj5; 2 Phase3Objs with other 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"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj3 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there is one issue + Reason: MultipleIssuesReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Obj obj0; Message-2 from Phase3Obj obj1; Message-3 from Phase3Obj 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"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj2 + {{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: MultipleIssuesReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Obj obj0; Message-2 from Phase3Obj obj1; Message-4 from Phase3Obj obj3; 1 Phase3Obj with status 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"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-4", Message: "Message-4"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-5", Message: "Message-5"}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionUnknown, Reason: "Reason-3", Message: "Message-3"}}, // obj5 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-99", Message: "Message-99"}}, // obj6 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there is at least an unknown and no issue + Reason: MultipleUnknownReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Objs obj0, obj4; Message-2 from Phase3Obj obj1; Message-3 from Phase3Obj obj5; 2 Phase3Objs with status 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"}}, // obj0 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-2", Message: "Message-2"}}, // obj1 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-4", Message: "Message-4"}}, // obj2 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-5", Message: ""}}, // obj3 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-1", Message: "Message-1"}}, // obj4 + {{Type: AvailableCondition, Status: metav1.ConditionTrue, Reason: "Reason-3", Message: "Message-3"}}, // obj5 + }, + conditionType: AvailableCondition, + options: []AggregateOption{}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionTrue, // True because there are no issue and unknown + Reason: MultipleInfoReportedReason, // Using a generic reason + Message: "Message-1 from Phase3Objs obj0, obj4; Message-2 from Phase3Obj obj1; Message-3 from Phase3Obj obj5; 1 Phase3Obj with additional info", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Missing conditions are defaulted", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {}, // obj2 without available condition + }, + 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: "Message-1 from Phase3Obj obj0; Condition Available not yet reported from Phase3Obj obj1", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Missing conditions are defaulted why a custom target condition type", + conditions: [][]metav1.Condition{ + {{Type: AvailableCondition, Status: metav1.ConditionFalse, Reason: "Reason-1", Message: "Message-1"}}, // obj0 + {}, // obj2 without available condition + }, + conditionType: AvailableCondition, + options: []AggregateOption{TargetConditionType("SomethingAvailable")}, + want: &metav1.Condition{ + Type: "SomethingAvailable", + Status: metav1.ConditionFalse, // False because there is one issue + Reason: "Reason-1", // Picking the reason from the only existing issue + Message: "Message-1 from Phase3Obj obj0; Condition Available not yet reported from Phase3Obj obj1", // 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, &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: fmt.Sprintf("obj%d", i), + }, + Status: builder.Phase3ObjStatus{ + Conditions: tt.conditions[i], + }, + }) + } + + got, err := NewAggregateCondition(objs, tt.conditionType, tt.options...) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(got).To(Equal(tt.want)) + }) + } + + t.Run("Fails if source objects are empty", func(t *testing.T) { + g := NewWithT(t) + _, err := NewAggregateCondition(nil, AvailableCondition) + g.Expect(err).To(HaveOccurred()) + }) +} diff --git a/util/conditions/v1beta2/doc.go b/util/conditions/v1beta2/doc.go new file mode 100644 index 000000000000..6118dcc83d0a --- /dev/null +++ b/util/conditions/v1beta2/doc.go @@ -0,0 +1,32 @@ +/* +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 v1beta2 implements utils for metav1.Conditions that will be used starting with the v1beta2 API. +// +// Please note that in order to make this change while respecting API deprecation rules, it is required +// to go through a phased approach: +// - Phase 1. metav1.Conditions will be added into v1beta1 API types under the Status.V1Beta2.Conditions struct (clusterv1.Conditions will remain in Status.Conditions) +// - Phase 2. when introducing v1beta2 API types: +// - clusterv1.Conditions will be moved from Status.Conditions to Status.Deprecated.V1Beta1.Conditions +// - metav1.Conditions will be moved from Status.V1Beta2.Conditions to Status.Conditions +// +// - Phase 3. when removing v1beta1 API types, Status.Deprecated will be dropped. +// +// Please see the proposal ... for more details TODO add link +// +// In order to make this transition easier both for CAPI and other projects using this package, +// utils automatically adapt to handle objects at different stage of the transition. +package v1beta2 diff --git a/util/conditions/v1beta2/getter.go b/util/conditions/v1beta2/getter.go new file mode 100644 index 000000000000..7ab7b79101ea --- /dev/null +++ b/util/conditions/v1beta2/getter.go @@ -0,0 +1,293 @@ +/* +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 v1beta2 + +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" +) + +// TODO: Move to the API package. +const ( + // NoReasonReported identifies a clusterv1.Condition that reports no reason. + NoReasonReported = "NoReasonReported" +) + +// Get returns a condition from the sourceObj. +// +// Get supports retrieving conditions from objects at different stages of the transition to the metav1.Condition type: +// - Objects with clusterv1.Conditions in status.conditions; in this case a best effort conversion +// to metav1.Condition is performed, just enough to allow surfacing a condition from a provider object with Mirror +// - Objects with metav1.Condition in status.v1beta2.conditions +// - Objects with metav1.Condition in status.conditions +// +// Please note that Get also supports reading conditions from unstructured objects; in this case, best effort +// conversion from a map is performed, just enough to allow surfacing a condition from a providers object with Mirror +// +// In case the object does not have metav1.Conditions, Get tries to read clusterv1.Conditions from status.conditions +// and convert them to metav1.Conditions. +func Get(sourceObj runtime.Object, sourceConditionType string) (*metav1.Condition, error) { + conditions, err := GetAll(sourceObj) + if err != nil { + return nil, err + } + return meta.FindStatusCondition(conditions, sourceConditionType), nil +} + +// GetAll returns all the conditions from the object. +// +// GetAll supports retrieving conditions from objects at different stages of the transition to the metav1.Condition type: +// - Objects with clusterv1.Conditions in status.conditions; in this case a best effort conversion +// to metav1.Condition is performed, just enough to allow surfacing a condition from a providers object with Mirror +// - Objects with metav1.Condition in status.v1beta2.conditions +// - Objects with metav1.Condition in status.conditions +// +// Please note that GetAll also supports reading conditions from unstructured objects; in this case, best effort +// conversion from a map is performed, just enough to allow surfacing a condition from a providers object with Mirror +// +// In case the object does not have metav1.Conditions, GetAll tries to read clusterv1.Conditions from status.conditions +// and convert them to metav1.Conditions. +func GetAll(sourceObj runtime.Object) ([]metav1.Condition, error) { + if sourceObj == nil { + return nil, errors.New("sourceObj cannot be nil") + } + + switch sourceObj.(type) { + case runtime.Unstructured: + return getFromUnstructuredObject(sourceObj) + default: + return getFromTypedObject(sourceObj) + } +} + +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 func + 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") + } + + ownerInfo := getConditionOwnerInfo(obj) + + value, exists, err := unstructured.NestedFieldNoCopy(u.UnstructuredContent(), "status", "v1beta2", "conditions") + if exists && err == nil { + if conditions, ok := value.([]interface{}); ok { + r, err := convertFromUnstructuredConditions(conditions) + if err != nil { + return nil, errors.Wrapf(err, "failed to convert %s.status.v1beta2.conditions to []metav1.Condition", ownerInfo) + } + return r, nil + } + } + + value, exists, err = unstructured.NestedFieldNoCopy(u.UnstructuredContent(), "status", "conditions") + if exists && err == nil { + if conditions, ok := value.([]interface{}); ok { + r, err := convertFromUnstructuredConditions(conditions) + if err != nil { + return nil, errors.Wrapf(err, "failed to convert %s.status.conditions to []metav1.Condition", ownerInfo) + } + return r, nil + } + } + + return nil, errors.Errorf("%s must have status with one of conditions or v1beta2.conditions", ownerInfo) +} + +// convertFromUnstructuredConditions converts []interface{} to []metav1.Condition; this operation must account for +// objects which are not transitioning to metav1.Condition, or not yet fully transitioned, and thus a best +// effort conversion of values to metav1.Condition is performed. +func convertFromUnstructuredConditions(conditions []interface{}) ([]metav1.Condition, error) { + if conditions == nil { + return nil, nil + } + + convertedConditions := make([]metav1.Condition, 0, len(conditions)) + for _, c := range conditions { + cMap, ok := c.(map[string]interface{}) + if !ok || cMap == nil { + 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 && v != nil && v.(string) != "" { + if err := lastTransitionTime.UnmarshalQueryParameter(v.(string)); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal lastTransitionTime value: %s", v) + } + } + + var reason string + if v, ok := cMap["reason"]; ok { + reason = v.(string) + } + + var message string + if v, ok := cMap["message"]; ok { + message = v.(string) + } + + c := metav1.Condition{ + Type: conditionType, + Status: metav1.ConditionStatus(status), + ObservedGeneration: observedGeneration, + LastTransitionTime: lastTransitionTime, + Reason: reason, + Message: message, + } + if err := validateAndFixConvertedCondition(&c); err != nil { + return nil, err + } + + convertedConditions = append(convertedConditions, c) + } + return convertedConditions, nil +} + +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)") + } + + ownerInfo := getConditionOwnerInfo(obj) + + statusField := elem.FieldByName("Status") + if statusField == (reflect.Value{}) { + return nil, errors.Errorf("%s must have a status field", ownerInfo) + } + + // 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.Condition. + // In order to handle this, first try to read Status.V1Beta2.Conditions, then Status.Conditions; for Status.Conditions, also support conversion from legacy conditions. + // The V1Beta2 branch and the conversion from legacy conditions should be dropped when the v1beta1 API is removed. + + if v1beta2Field := statusField.FieldByName("V1Beta2"); v1beta2Field != (reflect.Value{}) { + if v1beta2Field.Kind() != reflect.Pointer { + return nil, errors.Errorf("%s.status.v1beta2 must be a pointer", ownerInfo) + } + + v1beta2Elem := v1beta2Field.Elem() + if !v1beta2Elem.IsValid() { + return nil, errors.Errorf("%s.status.v1beta2 must be a valid value (non zero value of its type)", ownerInfo) + } + + if conditionField := v1beta2Elem.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + v1beta2Conditions, ok := conditionField.Interface().([]metav1.Condition) + if !ok { + return nil, errors.Errorf("%s.status.v1beta2.conditions must be of type []metav1.Condition", ownerInfo) + } + return v1beta2Conditions, nil + } + } + + if conditionField := statusField.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + conditions, ok := conditionField.Interface().([]metav1.Condition) + if ok { + return conditions, nil + } + + v1betaConditions, ok := conditionField.Interface().(clusterv1.Conditions) + if !ok { + return nil, errors.Errorf("%s.status.conditions must be of type []metav1.Condition or clusterv1.Conditions", ownerInfo) + } + r, err := convertFromV1Beta1Conditions(v1betaConditions) + if err != nil { + return nil, errors.Wrapf(err, "failed to convert %s.status.conditions to []metav1.Condition", ownerInfo) + } + return r, nil + } + + return nil, errors.Errorf("%s.status must have one of conditions or v1beta2.conditions", ownerInfo) +} + +// convertFromV1Beta1Conditions converts a clusterv1.Conditions to []metav1.Condition. +// NOTE: this operation is performed at best effort and assuming conditions have been set using Cluster API condition utils. +func convertFromV1Beta1Conditions(v1betaConditions clusterv1.Conditions) ([]metav1.Condition, error) { + 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, + } + + if err := validateAndFixConvertedCondition(&convertedConditions[i]); err != nil { + return nil, err + } + } + return convertedConditions, nil +} + +// validateAndFixConvertedCondition validates and fixes a clusterv1.Condition converted to a metav1.Condition. +// this operation assumes conditions have been set using Cluster API condition utils; +// also, only a few, minimal rules are enforced, just enough to allow surfacing a condition from a providers object with Mirror. +func validateAndFixConvertedCondition(c *metav1.Condition) error { + if c.Type == "" { + return errors.New("condition type must be set") + } + if c.Status == "" { + return errors.New("condition status must be set") + } + if c.Reason == "" { + switch c.Status { + case metav1.ConditionFalse: // When using old Cluster API condition utils, for conditions with Status false, Reason can be empty only when a condition has negative polarity (means "good") + c.Reason = NoReasonReported + case metav1.ConditionTrue: // When using old Cluster API condition utils, for conditions with Status true, Reason can be empty only when a condition has positive polarity (means "good"). + c.Reason = NoReasonReported + case metav1.ConditionUnknown: + return errors.New("condition reason must be set when a condition is unknown") + } + } + + // NOTE: Empty LastTransitionTime is tolerated because it will be set when assigning the newly generated mirror condition to an object. + // NOTE: Other metav1.Condition validations rules, e.g. regex, are not enforced at this stage; they will be enforced by the API server at a later stage. + + return nil +} diff --git a/util/conditions/v1beta2/getter_test.go b/util/conditions/v1beta2/getter_test.go new file mode 100644 index 000000000000..7f3f2dcd92fc --- /dev/null +++ b/util/conditions/v1beta2/getter_test.go @@ -0,0 +1,479 @@ +/* +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 v1beta2 + +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" + "sigs.k8s.io/cluster-api/internal/test/builder" +) + +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 *ObjWithWrongV1beta2Type) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type ObjWithWrongV1beta2Type struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions clusterv1.Conditions + V1Beta2 struct{} + } +} + +func (f *ObjWithWrongConditionType) DeepCopyObject() runtime.Object { + panic("implement me") +} + +type ObjWithWrongV1beta2ConditionType struct { + metav1.TypeMeta + metav1.ObjectMeta + Status struct { + Conditions clusterv1.Conditions + V1Beta2 *struct { + Conditions []string + } + } +} + +func (f *ObjWithWrongV1beta2ConditionType) 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 *builder.Phase0Obj + + _, 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 wrong condition type", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongConditionType{} + + _, 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 wrong v1beta2 type", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongV1beta2Type{} + + _, 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 nil v1beta2", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongV1beta2ConditionType{ + Status: struct { + Conditions clusterv1.Conditions + V1Beta2 *struct{ Conditions []string } + }{ + Conditions: nil, + V1Beta2: nil, + }, + } + + _, 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 wrong v1beta2 condition type", func(t *testing.T) { + g := NewWithT(t) + foo := &ObjWithWrongV1beta2ConditionType{ + Status: struct { + Conditions clusterv1.Conditions + V1Beta2 *struct{ Conditions []string } + }{ + Conditions: nil, + V1Beta2: &struct{ Conditions []string }{}, + }, + } + + _, 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 v1beta2 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("v1beta object with legacy conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase0Obj{ + Status: builder.Phase0ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + 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, + Reason: NoReasonReported, + }, + { + 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(MatchConditions(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(MatchConditions(expect), cmp.Diff(got, expect)) + }) + + t.Run("v1beta1 object with both legacy and v1beta2 conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase1Obj{ + Status: builder.Phase1ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + V1Beta2: &builder.Phase1ObjStatusV1Beta2{ + Conditions: []metav1.Condition{ + { + Type: "fooCondition", + Status: metav1.ConditionTrue, + ObservedGeneration: 10, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + }, + }, + }, + }, + } + + expect := []metav1.Condition{ + { + Type: foo.Status.V1Beta2.Conditions[0].Type, + Status: foo.Status.V1Beta2.Conditions[0].Status, + LastTransitionTime: foo.Status.V1Beta2.Conditions[0].LastTransitionTime, + ObservedGeneration: foo.Status.V1Beta2.Conditions[0].ObservedGeneration, + Reason: foo.Status.V1Beta2.Conditions[0].Reason, + Message: foo.Status.V1Beta2.Conditions[0].Message, + }, + } + + got, err := GetAll(foo) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(MatchConditions(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(MatchConditions(expect), cmp.Diff(got, expect)) + }) + + t.Run("v1beta2 object with conditions and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "fooCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "fooReason", + }, + }, + Deprecated: &builder.Phase2ObjStatusDeprecated{ + V1Beta1: &builder.Phase2ObjStatusDeprecatedV1Beta1{ + 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, + Reason: foo.Status.Conditions[0].Reason, + }, + } + + got, err := GetAll(foo) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(MatchConditions(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(MatchConditions(expect), cmp.Diff(got, expect)) + }) + + t.Run("v1beta2 object with conditions (end state)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + Conditions: []metav1.Condition{ + { + Type: "fooCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "fooReason", + }, + }, + }, + } + + expect := []metav1.Condition{ + { + Type: foo.Status.Conditions[0].Type, + Status: foo.Status.Conditions[0].Status, + LastTransitionTime: foo.Status.Conditions[0].LastTransitionTime, + Reason: foo.Status.Conditions[0].Reason, + }, + } + + got, err := GetAll(foo) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(got).To(MatchConditions(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(MatchConditions(expect), cmp.Diff(got, expect)) + }) +} + +func TestConvertFromV1Beta1Conditions(t *testing.T) { + tests := []struct { + name string + conditions []clusterv1.Condition + want []metav1.Condition + wantError bool + }{ + { + name: "Fails if Type is missing", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Status: corev1.ConditionTrue}, + }, + wantError: true, + }, + { + name: "Fails if Status is missing", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ConditionType("foo")}, + }, + wantError: true, + }, + { + name: "Defaults reason for positive polarity", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ConditionType("foo"), Status: corev1.ConditionTrue}, + }, + wantError: false, + want: []metav1.Condition{ + { + Type: "foo", + Status: metav1.ConditionTrue, + Reason: NoReasonReported, + }, + }, + }, + { + name: "Defaults reason for negative polarity", + conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ConditionType("foo"), Status: corev1.ConditionFalse}, + }, + wantError: false, + want: []metav1.Condition{ + { + Type: "foo", + Status: metav1.ConditionFalse, + Reason: NoReasonReported, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got, err := convertFromV1Beta1Conditions(tt.conditions) + if tt.wantError { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).NotTo(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want), cmp.Diff(tt.want, got)) + }) + t.Run(tt.name+" - unstructured", func(t *testing.T) { + g := NewWithT(t) + + unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(&builder.Phase0Obj{Status: builder.Phase0ObjStatus{Conditions: tt.conditions}}) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(unstructuredObj).To(HaveKey("status")) + unstructuredStatusObj := unstructuredObj["status"].(map[string]interface{}) + g.Expect(unstructuredStatusObj).To(HaveKey("conditions")) + + got, err := convertFromUnstructuredConditions(unstructuredStatusObj["conditions"].([]interface{})) + if tt.wantError { + g.Expect(err).To(HaveOccurred()) + } else { + g.Expect(err).NotTo(HaveOccurred()) + } + g.Expect(got).To(Equal(tt.want), cmp.Diff(tt.want, got)) + }) + } +} diff --git a/util/conditions/v1beta2/matcher.go b/util/conditions/v1beta2/matcher.go new file mode 100644 index 000000000000..d842b2136e19 --- /dev/null +++ b/util/conditions/v1beta2/matcher.go @@ -0,0 +1,146 @@ +/* +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 v1beta2 + +import ( + "fmt" + + "github.com/onsi/gomega" + "github.com/onsi/gomega/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// IgnoreLastTransitionTime instructs MatchConditions and MatchCondition to ignore the LastTransitionTime field. +type IgnoreLastTransitionTime bool + +// ApplyMatch applies this configuration to the given Match options. +func (f IgnoreLastTransitionTime) ApplyMatch(opts *MatchOptions) { + opts.ignoreLastTransitionTime = bool(f) +} + +// MatchOption is some configuration that modifies options for a match call. +type MatchOption interface { + // ApplyMatch applies this configuration to the given match options. + ApplyMatch(option *MatchOptions) +} + +// MatchOptions allows to set options for the match operation. +type MatchOptions struct { + ignoreLastTransitionTime bool +} + +// ApplyOptions applies the given list options on these options, +// and then returns itself (for convenient chaining). +func (o *MatchOptions) ApplyOptions(opts []MatchOption) *MatchOptions { + for _, opt := range opts { + opt.ApplyMatch(o) + } + return o +} + +// MatchConditions returns a custom matcher to check equality of []metav1.Condition. +func MatchConditions(expected []metav1.Condition, opts ...MatchOption) types.GomegaMatcher { + return &matchConditions{ + opts: opts, + expected: expected, + } +} + +type matchConditions struct { + opts []MatchOption + expected []metav1.Condition +} + +func (m matchConditions) Match(actual interface{}) (success bool, err error) { + elems := []interface{}{} + for _, condition := range m.expected { + elems = append(elems, MatchCondition(condition, m.opts...)) + } + + return gomega.ConsistOf(elems...).Match(actual) +} + +func (m matchConditions) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto match\n\t%#v\n", actual, m.expected) +} + +func (m matchConditions) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto not match\n\t%#v\n", actual, m.expected) +} + +// MatchCondition returns a custom matcher to check equality of metav1.Condition. +func MatchCondition(expected metav1.Condition, opts ...MatchOption) types.GomegaMatcher { + return &matchCondition{ + opts: opts, + expected: expected, + } +} + +type matchCondition struct { + opts []MatchOption + expected metav1.Condition +} + +func (m matchCondition) Match(actual interface{}) (success bool, err error) { + matchOpt := &MatchOptions{ + ignoreLastTransitionTime: false, + } + matchOpt.ApplyOptions(m.opts) + + actualCondition, ok := actual.(metav1.Condition) + if !ok { + return false, fmt.Errorf("actual should be of type metav1.Condition") + } + + ok, err = gomega.Equal(m.expected.Type).Match(actualCondition.Type) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Status).Match(actualCondition.Status) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.ObservedGeneration).Match(actualCondition.ObservedGeneration) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Reason).Match(actualCondition.Reason) + if !ok { + return ok, err + } + ok, err = gomega.Equal(m.expected.Message).Match(actualCondition.Message) + if !ok { + return ok, err + } + + if !matchOpt.ignoreLastTransitionTime { + ok, err = gomega.BeTemporally("==", m.expected.LastTransitionTime.Time).Match(actualCondition.LastTransitionTime.Time) + if !ok { + return ok, err + } + } + + return ok, err +} + +func (m matchCondition) FailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto match\n\t%#v\n", actual, m.expected) +} + +func (m matchCondition) NegatedFailureMessage(actual interface{}) (message string) { + return fmt.Sprintf("expected\n\t%#v\nto not match\n\t%#v\n", actual, m.expected) +} diff --git a/util/conditions/v1beta2/matcher_test.go b/util/conditions/v1beta2/matcher_test.go new file mode 100644 index 000000000000..d51496ae9300 --- /dev/null +++ b/util/conditions/v1beta2/matcher_test.go @@ -0,0 +1,327 @@ +/* +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 v1beta2 + +import ( + "testing" + "time" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestMatchConditions(t *testing.T) { + t0 := metav1.Now() + + testCases := []struct { + name string + actual interface{} + expected []metav1.Condition + expectMatch bool + }{ + { + name: "with an empty conditions", + actual: []metav1.Condition{}, + expected: []metav1.Condition{}, + expectMatch: true, + }, + { + name: "with matching conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expectMatch: true, + }, + { + name: "with non-matching conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + { + Type: "different", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "different", + Message: "different", + }, + }, + expectMatch: false, + }, + { + name: "with a different number of conditions", + actual: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expected: []metav1.Condition{ + { + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + }, + expectMatch: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + if tc.expectMatch { + g.Expect(tc.actual).To(MatchConditions(tc.expected)) + } else { + g.Expect(tc.actual).ToNot(MatchConditions(tc.expected)) + } + }) + } +} + +func TestMatchCondition(t *testing.T) { + t0 := metav1.Now() + t1 := metav1.NewTime(t0.Add(1 * -time.Minute)) + + testCases := []struct { + name string + actual interface{} + expected metav1.Condition + options []MatchOption + expectMatch bool + }{ + { + name: "with an empty condition", + actual: metav1.Condition{}, + expected: metav1.Condition{}, + options: []MatchOption{}, + expectMatch: true, + }, + { + name: "with a matching condition", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: true, + }, + { + name: "with a different LastTransitionTime", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t1, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different LastTransitionTime but with IgnoreLastTransitionTime", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t1, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{IgnoreLastTransitionTime(true)}, + expectMatch: true, + }, + { + name: "with a different type", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "different", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different status", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionFalse, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different ObservedGeneration", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + ObservedGeneration: 1, + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + ObservedGeneration: 2, + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different reason", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "different", + Message: "message", + }, + options: []MatchOption{}, + expectMatch: false, + }, + { + name: "with a different message", + actual: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "message", + }, + expected: metav1.Condition{ + Type: "type", + Status: metav1.ConditionTrue, + LastTransitionTime: t0, + Reason: "reason", + Message: "different", + }, + options: []MatchOption{}, + expectMatch: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + g := NewWithT(t) + if tc.expectMatch { + g.Expect(tc.actual).To(MatchCondition(tc.expected, tc.options...)) + } else { + g.Expect(tc.actual).ToNot(MatchCondition(tc.expected, tc.options...)) + } + }) + } +} diff --git a/util/conditions/v1beta2/merge_strategies.go b/util/conditions/v1beta2/merge_strategies.go new file mode 100644 index 000000000000..4b166af5bfe2 --- /dev/null +++ b/util/conditions/v1beta2/merge_strategies.go @@ -0,0 +1,430 @@ +/* +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 v1beta2 + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/gobuffalo/flect" + "github.com/pkg/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" +) + +// TODO: Move to the API package. +const ( + // MultipleIssuesReportedReason is set on conditions generated during aggregate or summary operations when multiple conditions/objects are reporting issues. + // NOTE: If a custom merge strategy is used for the aggregate or summary operations, this might not be true anymore. + MultipleIssuesReportedReason = "MultipleIssuesReported" + + // MultipleUnknownReportedReason is set on conditions generated during aggregate or summary operations when multiple conditions/objects are reporting unknown. + // NOTE: If a custom merge strategy is used for the aggregate or summary operations, this might not be true anymore. + MultipleUnknownReportedReason = "MultipleUnknownReported" + + // MultipleInfoReportedReason is set on conditions generated during aggregate or summary operations when multiple conditions/objects are reporting info. + // NOTE: If a custom merge strategy is used for the aggregate or summary operations, this might not be true anymore. + MultipleInfoReportedReason = "MultipleInfoReported" +) + +// MergeStrategy defines a strategy used to merge conditions during the aggregate or summary operation. +type MergeStrategy interface { + // Merge passed in conditions. + // + // It is up to the caller to ensure that all the expected conditions exist (e.g. by adding new conditions with status Unknown). + // Conditions passed in 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. + // + // If stepCounter is true, the implementation of merge must add info about step progress 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 around metav1.Condition with additional ConditionOwnerInfo. +// These infos can be used when generating the message resulting from the merge operation. +type ConditionWithOwnerInfo struct { + OwnerResource ConditionOwnerInfo + metav1.Condition +} + +// ConditionOwnerInfo contains infos about the object that owns the condition. +type ConditionOwnerInfo struct { + Kind string + Name string +} + +// String returns a string representation of the ConditionOwnerInfo. +func (o ConditionOwnerInfo) String() string { + return fmt.Sprintf("%s %s", o.Kind, o.Name) +} + +// 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 conditions in input based on a strategy that surfaces issues first, then unknown conditions, then 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) { + if len(conditions) == 0 { + return "", "", "", errors.New("can't merge an empty list of conditions") + } + + // 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 a summary operation + // (Summary should merge different conditions from the same object) + 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 returns a condition with positive polarity. + // - if there are issues, use false + // - else if there are unknown, use unknown + // - else if there 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 + default: + // NOTE: this is already handled above, but repeating also here for better readability. + return "", "", "", errors.New("can't merge an empty list of conditions") + } + + // 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 = MultipleIssuesReportedReason + case len(unknownConditions) == 1: + reason = unknownConditions[0].Reason + case len(unknownConditions) > 1: + reason = MultipleUnknownReportedReason + case len(infoConditions) == 1: + reason = infoConditions[0].Reason + case len(infoConditions) > 1: + reason = MultipleInfoReportedReason + default: + // NOTE: this is already handled above, but repeating also here for better readability. + return "", "", "", errors.New("can't merge an empty list of conditions") + } + + // 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 from 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 considered 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 or unknown. + if status != metav1.ConditionTrue { + continue + } + // Drop info conditions with empty messages. + if condition.Message == "" { + continue + } + } + + var m string + if condition.Message != "" { + m = fmt.Sprintf("%s: %s", condition.Type, condition.Message) + } else { + m = fmt.Sprintf("%s: No additional info provided", condition.Type) + } + 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 obj0, obj1, obj2 and 2 more 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. ...; 2 more Objects with issues; 1 more Objects with unknown status + // + if isAggregateOperation { + 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 := aggregateMessages(issueConditions, &n, false, "with other issues") + messages = append(messages, issueMessages...) + } + + // 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 := aggregateMessages(unknownConditions, &n, false, "with status unknown") + messages = append(messages, unknownMessages...) + } + + // 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 := aggregateMessages(infoConditions, &n, true, "with additional info") + messages = append(messages, infoMessages...) + } + + 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. +// It assigns following priority values to conditions: +// - 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 conditions with unknown status. + return unknownMergePriority +} + +// aggregateMessages returns messages for the aggregate operation. +func aggregateMessages(conditions []ConditionWithOwnerInfo, n *int, dropEmpty bool, otherMessage string) (messages []string) { + // create a map with all the messages and the list of objects reporting the same message. + messageObjMap := map[string]map[string][]string{} + for _, condition := range conditions { + if dropEmpty && condition.Message == "" { + continue + } + + m := condition.Message + if _, ok := messageObjMap[condition.OwnerResource.Kind]; !ok { + messageObjMap[condition.OwnerResource.Kind] = map[string][]string{} + } + messageObjMap[condition.OwnerResource.Kind][m] = append(messageObjMap[condition.OwnerResource.Kind][m], condition.OwnerResource.Name) + } + + // Gets the objects kind (with a stable order). + kinds := make([]string, 0, len(messageObjMap)) + for kind := range messageObjMap { + kinds = append(kinds, kind) + } + sort.Strings(kinds) + + // Aggregate messages for each object kind. + for _, kind := range kinds { + kindPlural := flect.Pluralize(kind) + messageObjMapForKind := messageObjMap[kind] + + // 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(messageObjMapForKind)) + for m := range messageObjMapForKind { + messageIndex = append(messageIndex, m) + } + + sort.SliceStable(messageIndex, func(i, j int) bool { + return len(messageObjMapForKind[messageIndex[i]]) > len(messageObjMapForKind[messageIndex[j]]) || + (len(messageObjMapForKind[messageIndex[i]]) == len(messageObjMapForKind[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, + var other = 0 + for _, m := range messageIndex { + if *n == 0 { + other += len(messageObjMapForKind[m]) + continue + } + + msg := m + allObjects := messageObjMapForKind[m] + switch { + case len(allObjects) == 0: + // This should never happen, entry in the map exists only when an object reports a message. + case len(allObjects) == 1: + msg += fmt.Sprintf(" from %s %s", kind, strings.Join(allObjects, ", ")) + case len(allObjects) <= 3: + msg += fmt.Sprintf(" from %s %s", kindPlural, strings.Join(allObjects, ", ")) + default: + msg += fmt.Sprintf(" from %s %s and %d more", kindPlural, strings.Join(allObjects[:3], ", "), len(allObjects)-3) + } + + messages = append(messages, msg) + *n-- + } + + if other == 1 { + messages = append(messages, fmt.Sprintf("%d %s %s", other, kind, otherMessage)) + } + if other > 1 { + messages = append(messages, fmt.Sprintf("%d %s %s", other, kindPlural, otherMessage)) + } + } + + return messages +} + +// 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 + } + ownerInfo := getConditionOwnerInfo(obj) + for _, condition := range conditions { + ret = append(ret, ConditionWithOwnerInfo{ + OwnerResource: ownerInfo, + Condition: condition, + }) + } + return ret, nil +} + +// getConditionOwnerInfo return the ConditionOwnerInfo for the given object. +// Note: Given that controller runtime often does not set typeMeta for objects, +// in case kind is missing we are falling back to the type name, which in most cases +// is the same as kind. +func getConditionOwnerInfo(obj runtime.Object) ConditionOwnerInfo { + var kind, name string + kind = obj.GetObjectKind().GroupVersionKind().Kind + + 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() + } + + return ConditionOwnerInfo{ + Kind: kind, + Name: name, + } +} diff --git a/util/conditions/v1beta2/merge_strategies_test.go b/util/conditions/v1beta2/merge_strategies_test.go new file mode 100644 index 000000000000..c216016528b0 --- /dev/null +++ b/util/conditions/v1beta2/merge_strategies_test.go @@ -0,0 +1,204 @@ +/* +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 v1beta2 + +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{Kind: "MachineDeployment", Name: "obj01"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj02"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj03"}, Condition: metav1.Condition{Type: "A", Message: "Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj04"}, Condition: metav1.Condition{Type: "A", Message: "Message-2", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj05"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj06"}, Condition: metav1.Condition{Type: "A", Message: "Message-3", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj07"}, Condition: metav1.Condition{Type: "A", Message: "Message-4", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj08"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj09"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineDeployment", Name: "obj10"}, Condition: metav1.Condition{Type: "A", Message: "Message-5", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineSet", Name: "obj11"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + {OwnerResource: ConditionOwnerInfo{Kind: "MachineSet", Name: "obj12"}, Condition: metav1.Condition{Type: "A", Message: "Message-1", Status: metav1.ConditionFalse}}, + } + + n := 3 + messages := aggregateMessages(conditions, &n, false, "with other issues") + + g.Expect(n).To(Equal(0)) + g.Expect(messages).To(Equal([]string{ + "Message-1 from MachineDeployments obj01, obj02, obj05 and 2 more", // MachineDeployments obj08, obj09 + "Message-2 from MachineDeployments obj03, obj04", + "Message-3 from MachineDeployment obj06", + "2 MachineDeployments with other issues", // MachineDeployments obj07 (Message-4), obj10 (Message-5) + "2 MachineSets with other issues", // MachineSet obj11, obj12 (Message-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/v1beta2/mirror.go b/util/conditions/v1beta2/mirror.go new file mode 100644 index 000000000000..f263a82237b2 --- /dev/null +++ b/util/conditions/v1beta2/mirror.go @@ -0,0 +1,99 @@ +/* +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 v1beta2 + +import ( + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +// 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. +// TODO: Move to the API package. +const NotYetReportedReason = "NotYetReported" + +// MirrorOption is some configuration that modifies options for a mirror call. +type MirrorOption interface { + // ApplyToMirror applies this configuration to the given mirror options. + ApplyToMirror(*MirrorOptions) +} + +// MirrorOptions allows to set options for the mirror operation. +type MirrorOptions struct { + targetConditionType 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 as the source condition, but this can be changed by using +// the TargetConditionType option. +func NewMirrorCondition(sourceObj runtime.Object, sourceConditionType string, opts ...MirrorOption) (*metav1.Condition, error) { + mirrorOpt := &MirrorOptions{ + targetConditionType: sourceConditionType, + } + mirrorOpt.ApplyOptions(opts) + + conditionOwner := getConditionOwnerInfo(sourceObj) + + condition, err := Get(sourceObj, sourceConditionType) + if err != nil { + return nil, err + } + if condition == nil { + return &metav1.Condition{ + Type: mirrorOpt.targetConditionType, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported from %s", sourceConditionType, conditionOwner), + // NOTE: LastTransitionTime and ObservedGeneration will be set when this condition is added to an object by calling Set. + }, nil + } + + return &metav1.Condition{ + Type: mirrorOpt.targetConditionType, + Status: condition.Status, + // NOTE: we are preserving the original transition time (when the underlying condition changed) + LastTransitionTime: condition.LastTransitionTime, + Reason: condition.Reason, + Message: strings.TrimSpace(fmt.Sprintf("%s (from %s)", condition.Message, conditionOwner)), + // NOTE: ObservedGeneration will be set when this condition is added to an object by calling Set + // (also preserving ObservedGeneration from the source object will be confusing when the mirror conditions shows up in the target object). + }, 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, sourceConditionType string, opts ...MirrorOption) error { + mirrorCondition, err := NewMirrorCondition(sourceObj, sourceConditionType, opts...) + if err != nil { + return err + } + return Set(targetObj, *mirrorCondition) +} diff --git a/util/conditions/v1beta2/mirror_test.go b/util/conditions/v1beta2/mirror_test.go new file mode 100644 index 000000000000..3f0db0a623a3 --- /dev/null +++ b/util/conditions/v1beta2/mirror_test.go @@ -0,0 +1,100 @@ +/* +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 v1beta2 + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/cluster-api/internal/test/builder" +) + +func TestMirrorStatusCondition(t *testing.T) { + now := metav1.Now().Rfc3339Copy() + 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 Phase3Obj SourceObject)", LastTransitionTime: now}, + }, + { + name: "Mirror a condition with target type", + conditions: []metav1.Condition{ + {Type: "Ready", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good!", ObservedGeneration: 10, LastTransitionTime: now}, + }, + conditionType: "Ready", + options: []MirrorOption{TargetConditionType("SomethingReady")}, + want: metav1.Condition{Type: "SomethingReady", Status: metav1.ConditionTrue, Reason: "AllGood!", Message: "We are good! (from Phase3Obj SourceObject)", 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 Phase3Obj SourceObject)", 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 Phase3Obj SourceObject"}, + }, + { + name: "Mirror a condition not yet reported with target type", + conditions: []metav1.Condition{}, + conditionType: "Ready", + options: []MirrorOption{TargetConditionType("SomethingReady")}, + want: metav1.Condition{Type: "SomethingReady", Status: metav1.ConditionUnknown, Reason: NotYetReportedReason, Message: "Condition Ready not yet reported from Phase3Obj SourceObject"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + obj := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "SourceObject", + }, + Status: builder.Phase3ObjStatus{ + Conditions: tt.conditions, + }, + } + + got, err := NewMirrorCondition(obj, tt.conditionType, tt.options...) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(got).ToNot(BeNil()) + g.Expect(*got).To(Equal(tt.want)) + }) + } +} diff --git a/util/conditions/v1beta2/options.go b/util/conditions/v1beta2/options.go new file mode 100644 index 000000000000..855c9c9de118 --- /dev/null +++ b/util/conditions/v1beta2/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 v1beta2 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// ConditionSortFunc defines the sort order when conditions are assigned to an object. +type ConditionSortFunc func(i, j metav1.Condition) bool + +// ApplyToSet applies this configuration to the given Set options. +func (f ConditionSortFunc) ApplyToSet(opts *SetOptions) { + opts.conditionSortFunc = f +} + +// TargetConditionType allows to specify the type of new mirror or aggregate conditions. +type TargetConditionType string + +// ApplyToMirror applies this configuration to the given mirror options. +func (t TargetConditionType) ApplyToMirror(opts *MirrorOptions) { + opts.targetConditionType = string(t) +} + +// ApplyToAggregate applies this configuration to the given aggregate options. +func (t TargetConditionType) ApplyToAggregate(opts *AggregateOptions) { + opts.targetConditionType = 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 +} + +// NegativePolarityConditionTypes allows to define polarity for some of the conditions in scope for a summary operation. +type NegativePolarityConditionTypes []string + +// ApplyToSummary applies this configuration to the given summary options. +func (t NegativePolarityConditionTypes) ApplyToSummary(opts *SummaryOptions) { + opts.negativePolarityConditionTypes = t +} + +// IgnoreTypesIfMissing allows to define conditions types that should be ignored (not defaulted to unknown) when performing a summary operation. +type IgnoreTypesIfMissing []string + +// ApplyToSummary applies this configuration to the given summary options. +func (t IgnoreTypesIfMissing) ApplyToSummary(opts *SummaryOptions) { + opts.ignoreTypesIfMissing = t +} + +// CustomMergeStrategy allows to define a custom merge strategy when creating new summary or aggregate conditions. +type CustomMergeStrategy struct { + MergeStrategy +} + +// ApplyToSummary applies this configuration to the given summary options. +func (t CustomMergeStrategy) ApplyToSummary(opts *SummaryOptions) { + opts.mergeStrategy = t +} + +// ApplyToAggregate applies this configuration to the given aggregate options. +func (t CustomMergeStrategy) ApplyToAggregate(opts *AggregateOptions) { + opts.mergeStrategy = t +} + +// StepCounter adds a step counter message to new summary conditions. +type StepCounter bool + +// ApplyToSummary applies this configuration to the given summary options. +func (t StepCounter) ApplyToSummary(opts *SummaryOptions) { + opts.stepCounter = bool(t) +} diff --git a/util/conditions/v1beta2/setter.go b/util/conditions/v1beta2/setter.go new file mode 100644 index 000000000000..d67b90c870a2 --- /dev/null +++ b/util/conditions/v1beta2/setter.go @@ -0,0 +1,181 @@ +/* +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 v1beta2 + +import ( + "reflect" + "sort" + + "github.com/pkg/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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 { + conditionSortFunc ConditionSortFunc +} + +// 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 supports adding conditions to objects at different stages of the transition to the metav1.Condition type: +// - Objects with metav1.Condition in status.v1beta2.conditions +// - Objects with metav1.Condition in status.conditions +// +// When setting a condition: +// - condition.ObservedGeneration will be set to object.Metadata.Generation. +// - If the condition does not exist and condition.LastTransitionTime is not set, time.Now is used. +// - If the condition already exists, condition.Status is changing and condition.LastTransitionTime is not set, time.Now is used. +// - If the condition already exists, condition.Status is NOT changing, all the fields can be changed except for condition.LastTransitionTime. +// +// Set can't be used with unstructured objects. +// +// Additionally, Set enforces the a default condition order (Available and Ready fist, everything else in alphabetical order), +// but this can be changed by using the ConditionSortFunc option. +func Set(targetObj runtime.Object, condition metav1.Condition, opts ...SetOption) error { + conditions, err := GetAll(targetObj) + if err != nil { + return err + } + + if objMeta, ok := targetObj.(metav1.Object); ok { + condition.ObservedGeneration = objMeta.GetGeneration() + } + + if changed := meta.SetStatusCondition(&conditions, condition); !changed { + return nil + } + + return SetAll(targetObj, conditions, opts...) +} + +// SetAll the conditions on the given object. +// +// SetAll supports adding conditions to objects at different stages of the transition to the metav1.Condition type: +// - Objects with metav1.Condition in status.v1beta2.conditions +// - Objects with metav1.Condition in status.conditions +// +// SetAll can't be used with unstructured objects. +// +// Additionally, SetAll enforce a default condition order (Available and Ready fist, everything else in alphabetical order), +// but this can be changed by using the ConditionSortFunc option. +func SetAll(targetObj runtime.Object, conditions []metav1.Condition, opts ...SetOption) error { + setOpt := &SetOptions{ + // By default, sort conditions by the default condition order: first available, then ready, then the other conditions in alphabetical order. + conditionSortFunc: defaultSortLessFunc, + } + setOpt.ApplyOptions(opts) + + if setOpt.conditionSortFunc != nil { + sort.SliceStable(conditions, func(i, j int) bool { + return setOpt.conditionSortFunc(conditions[i], conditions[j]) + }) + } + + switch targetObj.(type) { + case runtime.Unstructured: + return errors.New("cannot set conditions on unstructured objects") + default: + return setToTypedObject(targetObj, conditions) + } +} + +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 an 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)") + } + + ownerInfo := getConditionOwnerInfo(obj) + + statusField := elem.FieldByName("Status") + if statusField == (reflect.Value{}) { + return errors.Errorf("cannot set conditions on %s, status field is missing", ownerInfo) + } + + // 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.V1Beta2.Conditions, then Status.Conditions. + // The V1Beta2 branch should be dropped when the v1beta1 API is removed. + + if v1beta2Field := statusField.FieldByName("V1Beta2"); v1beta2Field != (reflect.Value{}) { + if v1beta2Field.Kind() != reflect.Pointer { + return errors.Errorf("%s.status.v1beta2 must be a pointer", ownerInfo) + } + + v1beta2Elem := v1beta2Field.Elem() + if !v1beta2Elem.IsValid() { + return errors.Errorf("%s.status.v1beta2 must be a valid value (non zero value of its type)", ownerInfo) + } + + if conditionField := v1beta2Elem.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + if conditionField.Type() != metav1ConditionsType { + return errors.Errorf("cannot set conditions on %s.status.v1beta2.conditions, the field doesn't have []metav1.Condition type: %s type detected", ownerInfo, reflect.TypeOf(conditionField.Interface()).String()) + } + + setToTypedField(conditionField, conditions) + return nil + } + } + + if conditionField := statusField.FieldByName("Conditions"); conditionField != (reflect.Value{}) { + if conditionField.Type() != metav1ConditionsType { + return errors.Errorf("cannot set conditions on %s.status.conditions, the field doesn't have []metav1.Condition type: %s type detected", ownerInfo, reflect.TypeOf(conditionField.Interface()).String()) + } + + setToTypedField(conditionField, conditions) + return nil + } + + return errors.Errorf("cannot set conditions on %s both status.v1beta2.conditions and status.conditions fields are missing", ownerInfo) +} + +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/v1beta2/setter_test.go b/util/conditions/v1beta2/setter_test.go new file mode 100644 index 000000000000..e678cd085890 --- /dev/null +++ b/util/conditions/v1beta2/setter_test.go @@ -0,0 +1,173 @@ +/* +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 v1beta2 + +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" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" + "sigs.k8s.io/cluster-api/internal/test/builder" +) + +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 *builder.Phase0Obj + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("fails for Unstructured", func(t *testing.T) { + g := NewWithT(t) + fooUnstructured := &unstructured.Unstructured{} + + conditions := cloneConditions() + err := SetAll(fooUnstructured, conditions) + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("v1beta1 object with legacy conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase0Obj{ + Status: builder.Phase0ObjStatus{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 v1beta2 conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase1Obj{ + Status: builder.Phase1ObjStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "barCondition", + Status: corev1.ConditionFalse, + LastTransitionTime: now, + }, + }, + V1Beta2: &builder.Phase1ObjStatusV1Beta2{Conditions: nil}, + }, + } + + conditions := cloneConditions() + err := SetAll(foo, conditions) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(foo.Status.V1Beta2.Conditions).To(Equal(conditions), cmp.Diff(foo.Status.V1Beta2.Conditions, conditions)) + }) + + t.Run("v1beta2 object with conditions and backward compatible conditions", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase2Obj{ + Status: builder.Phase2ObjStatus{ + Conditions: nil, + Deprecated: &builder.Phase2ObjStatusDeprecated{ + V1Beta1: &builder.Phase2ObjStatusDeprecatedV1Beta1{ + 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(MatchConditions(conditions), cmp.Diff(foo.Status.Conditions, conditions)) + }) + + t.Run("v1beta2 object with conditions (end state)", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + Status: builder.Phase3ObjStatus{ + 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("Set infers ObservedGeneration", func(t *testing.T) { + g := NewWithT(t) + foo := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{Generation: 123}, + Status: builder.Phase3ObjStatus{ + Conditions: nil, + }, + } + + condition := metav1.Condition{ + Type: "fooCondition", + Status: metav1.ConditionTrue, + LastTransitionTime: now, + Reason: "FooReason", + Message: "FooMessage", + } + + err := Set(foo, condition) + g.Expect(err).ToNot(HaveOccurred()) + + condition.ObservedGeneration = foo.Generation + conditions := []metav1.Condition{condition} + g.Expect(foo.Status.Conditions).To(Equal(conditions), cmp.Diff(foo.Status.Conditions, conditions)) + }) +} diff --git a/util/conditions/v1beta2/sort.go b/util/conditions/v1beta2/sort.go new file mode 100644 index 000000000000..978212021ddc --- /dev/null +++ b/util/conditions/v1beta2/sort.go @@ -0,0 +1,37 @@ +/* +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 v1beta2 + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +const ( + // AvailableCondition documents availability for an object. + // TODO: Move to the API package. + AvailableCondition = "Available" + + // ReadyCondition documents readiness for an object. + // TODO: Move to the API package. + ReadyCondition = "Ready" +) + +// defaultSortLessFunc returns true if a condition is less than another with regards to the +// 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 defaultSortLessFunc(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/v1beta2/sort_test.go b/util/conditions/v1beta2/sort_test.go new file mode 100644 index 000000000000..5f3e972634c1 --- /dev/null +++ b/util/conditions/v1beta2/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 v1beta2 + +import ( + "sort" + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestDefaultSortLessFunc(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 defaultSortLessFunc(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/v1beta2/summary.go b/util/conditions/v1beta2/summary.go new file mode 100644 index 000000000000..f49a857ef1fa --- /dev/null +++ b/util/conditions/v1beta2/summary.go @@ -0,0 +1,145 @@ +/* +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 v1beta2 + +import ( + "fmt" + + "github.com/pkg/errors" + 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 call. +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 + ignoreTypesIfMissing []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; the list of +// types of the conditions to be summarized must be provided with the ForConditionTypes option; +// conditions types with negative polarity, should be indicated with the NegativePolarityConditionTypes option. +// +// If any of the condition in scope does not exist in the source object, missing conditions are considered Unknown, reason NotYetReported. +// Use the IgnoreTypesIfMissing to exclude types from this option. +// +// If the StepCounter option, a message will be added at to the generated condition reporting how many condition +// are not reporting issues/unknown over the total number of conditions being summarized, thus allowing to +// provide users a indication of progress for multistep process like provisioning a machine. +// Additionally, it is possible to inject custom merge strategies using the CustomMergeStrategy option or +// to add a step counter to the generated message by using the StepCounter option. +func NewSummaryCondition(sourceObj runtime.Object, targetConditionType string, opts ...SummaryOption) (*metav1.Condition, error) { + summarizeOpt := &SummaryOptions{ + mergeStrategy: newDefaultMergeStrategy(), + } + summarizeOpt.ApplyOptions(opts) + + if len(summarizeOpt.conditionTypes) == 0 { + return nil, errors.New("option ForConditionTypes not provided or empty") + } + + conditions, err := getConditionsWithOwnerInfo(sourceObj) + if err != nil { + return nil, err + } + + expectedConditionTypes := sets.New[string](summarizeOpt.conditionTypes...) + ignoreTypesIfMissing := sets.New[string](summarizeOpt.ignoreTypesIfMissing...) + 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 not exist, so we are compliant with K8s guidelines + // (all missing conditions should be considered unknown). + + diff := expectedConditionTypes.Difference(existingConditionTypes).Difference(ignoreTypesIfMissing).UnsortedList() + if len(diff) > 0 { + conditionOwner := getConditionOwnerInfo(sourceObj) + + for _, c := range diff { + conditionsInScope = append(conditionsInScope, ConditionWithOwnerInfo{ + OwnerResource: conditionOwner, + Condition: metav1.Condition{ + Type: c, + Status: metav1.ConditionUnknown, + Reason: NotYetReportedReason, + Message: fmt.Sprintf("Condition %s not yet reported", c), + // NOTE: LastTransitionTime and ObservedGeneration are not relevant for merge. + }, + }) + } + } + + if len(conditionsInScope) == 0 { + return nil, errors.New("summary can't be performed when the list of conditions to be summarized is empty") + } + + 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: targetConditionType, + Status: status, + Reason: reason, + Message: message, + // NOTE: LastTransitionTime and ObservedGeneration will be set when this condition is added to an object by calling Set. + }, 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, targetConditionType string, opts ...SummaryOption) error { + mirrorCondition, err := NewSummaryCondition(sourceObj, targetConditionType, opts...) + if err != nil { + return err + } + return Set(targetObj, *mirrorCondition) +} diff --git a/util/conditions/v1beta2/summary_test.go b/util/conditions/v1beta2/summary_test.go new file mode 100644 index 000000000000..03cd6ae4a4ae --- /dev/null +++ b/util/conditions/v1beta2/summary_test.go @@ -0,0 +1,262 @@ +/* +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 v1beta2 + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "sigs.k8s.io/cluster-api/internal/test/builder" +) + +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"}, NegativePolarityConditionTypes{"!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: Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "One issue without message", + conditions: []metav1.Condition{ + {Type: "B", Status: metav1.ConditionTrue, Reason: "Reason-B"}, // info + {Type: "A", Status: metav1.ConditionTrue, Reason: "Reason-A"}, // info + {Type: "!C", Status: metav1.ConditionTrue, Reason: "Reason-!C"}, // issue + }, + conditionType: AvailableCondition, + options: []SummaryOption{ForConditionTypes{"A", "B", "!C"}, NegativePolarityConditionTypes{"!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: No additional info provided", // messages from all the issues & unknown conditions (info dropped); since message is empty, a default one is added + }, + }, + { + 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"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there are many issues + Reason: MultipleIssuesReportedReason, // Using a generic reason + Message: "B: Message-B; !C: 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"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionFalse, // False because there are many issues + Reason: MultipleIssuesReportedReason, // Using a generic reason + Message: "B: Message-B; !C: Message-!C; A: 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"}, NegativePolarityConditionTypes{"!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: 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"}, NegativePolarityConditionTypes{"!C"}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there are many unknown + Reason: MultipleUnknownReportedReason, // Using a generic reason + Message: "B: Message-B; !C: Message-!C", // messages from all the issues & unknown conditions (info dropped) + }, + }, + + { + 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"}, NegativePolarityConditionTypes{"!C"}, CustomMergeStrategy{newDefaultMergeStrategy()}}, + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionTrue, // True because there are many info + Reason: MultipleInfoReportedReason, // Using a generic reason + Message: "B: Message-B; !C: 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"}, NegativePolarityConditionTypes{"!C"}}, // B and !C are required! + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there more than one unknown + Reason: MultipleUnknownReportedReason, // Using a generic reason + Message: "B: Condition B not yet reported; !C: Condition !C not yet reported", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "Default missing conditions to unknown consider IgnoreTypesIfMissing", + 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"}, NegativePolarityConditionTypes{"!C"}, IgnoreTypesIfMissing{"B"}}, // B and !C are required! + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionUnknown, // Unknown because there more than one unknown + Reason: NotYetReportedReason, // Picking the reason from the only existing issue, which is a default missing condition added for !C + Message: "!C: Condition !C not yet reported", // messages from all the issues & unknown conditions (info dropped) + }, + }, + { + name: "No issue considering IgnoreTypesIfMissing", + 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"}, NegativePolarityConditionTypes{"!C"}, IgnoreTypesIfMissing{"B", "!C"}}, // A is required! + want: &metav1.Condition{ + Type: AvailableCondition, + Status: metav1.ConditionTrue, // True because B and !C are ignored + Reason: "Reason-A", // Picking the reason from A, the only existing info + Message: "A: Message-A", // messages from A, the only existing info + }, + }, + { + 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: MultipleInfoReportedReason, // Using a generic reason + Message: "B: 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"}, NegativePolarityConditionTypes{"!C"}, StepCounter(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: 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 := &builder.Phase3Obj{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + Name: "SourceObject", + }, + Status: builder.Phase3ObjStatus{ + Conditions: tt.conditions, + }, + } + + got, err := NewSummaryCondition(obj, tt.conditionType, tt.options...) + g.Expect(err).ToNot(HaveOccurred()) + + g.Expect(got).To(Equal(tt.want)) + }) + } + + t.Run("Fails if conditions type is not provided", func(t *testing.T) { + g := NewWithT(t) + obj := &builder.Phase3Obj{} + _, err := NewSummaryCondition(obj, AvailableCondition) // no ForConditionTypes --> Condition in scope will be empty + g.Expect(err).To(HaveOccurred()) + }) + + t.Run("Fails if conditions in scope are empty", func(t *testing.T) { + g := NewWithT(t) + obj := &builder.Phase3Obj{} + _, err := NewSummaryCondition(obj, AvailableCondition, ForConditionTypes{"A"}, IgnoreTypesIfMissing{"A"}) // no condition for the object, missing condition ignored --> Condition in scope will be empty + g.Expect(err).To(HaveOccurred()) + }) +}