diff --git a/cmd/clusterctl/client/describe.go b/cmd/clusterctl/client/describe.go index 2e98ef0325a3..fb0b5827d824 100644 --- a/cmd/clusterctl/client/describe.go +++ b/cmd/clusterctl/client/describe.go @@ -57,6 +57,9 @@ type DescribeClusterOptions struct { // Grouping groups machines objects in case the ready conditions // have the same Status, Severity and Reason. Grouping bool + + // V1Beta2 instructs tree to use V1Beta2 conditions. + V1Beta2 bool } // DescribeCluster returns the object tree representing the status of a Cluster API cluster. @@ -96,5 +99,6 @@ func (c *clusterctlClient) DescribeCluster(ctx context.Context, options Describe AddTemplateVirtualNode: options.AddTemplateVirtualNode, Echo: options.Echo, Grouping: options.Grouping, + V1Beta2: options.V1Beta2, }) } diff --git a/cmd/clusterctl/client/tree/annotations.go b/cmd/clusterctl/client/tree/annotations.go index afb18cebb127..a4c76be7d0d7 100644 --- a/cmd/clusterctl/client/tree/annotations.go +++ b/cmd/clusterctl/client/tree/annotations.go @@ -47,6 +47,19 @@ const ( // GroupItemsAnnotation contains the list of names for the objects included in a group object. GroupItemsAnnotation = "tree.cluster.x-k8s.io.io/group-items" + // GroupItemsAvailableCounter contains the number of available objects in the group, e.g. available Machines. + GroupItemsAvailableCounter = "tree.cluster.x-k8s.io.io/group-items-available-count" + + // GroupItemsReadyCounter contains the number of ready objects in the group, e.g. ready Machines. + GroupItemsReadyCounter = "tree.cluster.x-k8s.io.io/group-items-ready-count" + + // GroupItemsUpToDateCounter contains the number of up-to-date objects in the group, e.g. up-to-date Machines. + GroupItemsUpToDateCounter = "tree.cluster.x-k8s.io.io/group-items-up-to-date-count" + + // ObjectContractAnnotation is added to unstructured objects to track which Cluster API contract those objects abide to. + // Note: Currently this annotation is applied only to control plane objects. + ObjectContractAnnotation = "tree.cluster.x-k8s.io.io/object-contract" + // GroupItemsSeparator is the separator used in the GroupItemsAnnotation. GroupItemsSeparator = ", " @@ -91,6 +104,51 @@ func GetGroupItems(obj client.Object) string { return "" } +// GetGroupItemsAvailableCounter returns the number of available objects in the group, e.g. available Machines. +func GetGroupItemsAvailableCounter(obj client.Object) int { + val, ok := getAnnotation(obj, GroupItemsAvailableCounter) + if !ok { + return 0 + } + if v, err := strconv.Atoi(val); err == nil { + return v + } + return 0 +} + +// GetGroupItemsReadyCounter returns the number of ready objects in the group, e.g. ready Machines. +func GetGroupItemsReadyCounter(obj client.Object) int { + val, ok := getAnnotation(obj, GroupItemsReadyCounter) + if !ok { + return 0 + } + if v, err := strconv.Atoi(val); err == nil { + return v + } + return 0 +} + +// GetGroupItemsUpToDateCounter returns the number of up-to-date objects in the group, e.g. up-to-date Machines. +func GetGroupItemsUpToDateCounter(obj client.Object) int { + val, ok := getAnnotation(obj, GroupItemsUpToDateCounter) + if !ok { + return 0 + } + if v, err := strconv.Atoi(val); err == nil { + return v + } + return 0 +} + +// GetObjectContract returns which Cluster API contract an unstructured object abides to. +// Note: Currently this annotation is applied only to control plane objects. +func GetObjectContract(obj client.Object) string { + if val, ok := getAnnotation(obj, ObjectContractAnnotation); ok { + return val + } + return "" +} + // GetZOrder return the zOrder of the object. Objects with no zOrder have a default zOrder of 0. func GetZOrder(obj client.Object) int { if val, ok := getAnnotation(obj, ObjectZOrderAnnotation); ok { diff --git a/cmd/clusterctl/client/tree/discovery.go b/cmd/clusterctl/client/tree/discovery.go index c86d3b573f69..f4098c0e6ac6 100644 --- a/cmd/clusterctl/client/tree/discovery.go +++ b/cmd/clusterctl/client/tree/discovery.go @@ -22,7 +22,7 @@ import ( "github.com/pkg/errors" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - unstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/controller-runtime/pkg/client" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" @@ -56,6 +56,9 @@ type DiscoverOptions struct { // Grouping groups machine objects in case the ready conditions // have the same Status, Severity and Reason. Grouping bool + + // V1Beta2 instructs tree to use V1Beta2 conditions. + V1Beta2 bool } func (d DiscoverOptions) toObjectTreeOptions() ObjectTreeOptions { @@ -97,6 +100,10 @@ func Discovery(ctx context.Context, c client.Client, namespace, name string, opt // Adds control plane controlPlane, err := external.Get(ctx, c, cluster.Spec.ControlPlaneRef) if err == nil { + // Keep track that this objects abides to the Cluster API control plane contract, + // so the consumers of the ObjectTree will know which info are available on this unstructured object + // and how to extract them. + addAnnotation(controlPlane, ObjectContractAnnotation, "ControlPlane") addControlPlane(cluster, controlPlane, tree, options) } @@ -217,7 +224,7 @@ func addControlPlane(cluster *clusterv1.Cluster, controlPlane *unstructured.Unst } } -func addMachineDeploymentToObjectTree(ctx context.Context, c client.Client, cluster *clusterv1.Cluster, workers *unstructured.Unstructured, machinesList *clusterv1.MachineList, tree *ObjectTree, options DiscoverOptions, addMachineFunc func(parent client.Object, m *clusterv1.Machine)) error { +func addMachineDeploymentToObjectTree(ctx context.Context, c client.Client, cluster *clusterv1.Cluster, workers *NodeObject, machinesList *clusterv1.MachineList, tree *ObjectTree, options DiscoverOptions, addMachineFunc func(parent client.Object, m *clusterv1.Machine)) error { // Adds worker machines. machinesDeploymentList, err := getMachineDeploymentsInCluster(ctx, c, cluster.Namespace, cluster.Name) if err != nil { @@ -275,7 +282,7 @@ func addMachineDeploymentToObjectTree(ctx context.Context, c client.Client, clus return nil } -func addMachinePoolsToObjectTree(ctx context.Context, c client.Client, workers *unstructured.Unstructured, machinePoolList *expv1.MachinePoolList, machinesList *clusterv1.MachineList, tree *ObjectTree, addMachineFunc func(parent client.Object, m *clusterv1.Machine)) { +func addMachinePoolsToObjectTree(ctx context.Context, c client.Client, workers *NodeObject, machinePoolList *expv1.MachinePoolList, machinesList *clusterv1.MachineList, tree *ObjectTree, addMachineFunc func(parent client.Object, m *clusterv1.Machine)) { for i := range machinePoolList.Items { mp := &machinePoolList.Items[i] _, visible := tree.Add(workers, mp, GroupingObject(true)) diff --git a/cmd/clusterctl/client/tree/node_object.go b/cmd/clusterctl/client/tree/node_object.go new file mode 100644 index 000000000000..ab88cc546e10 --- /dev/null +++ b/cmd/clusterctl/client/tree/node_object.go @@ -0,0 +1,127 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tree + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" +) + +// NodeObject represent a node in the tree which doesn't correspond to Cluster, MachineDeployment, Machine etc. +// An example of NodeObject are GroupNodes, which are used e.g. to represent a set of Machines. +// Note: NodeObject implements condition getter and setter interfaces as well as the minimal set of methods +// usually existing on Kubernetes objects. +type NodeObject struct { + metav1.TypeMeta + metav1.ObjectMeta + Status NodeStatus +} + +// NodeStatus is the status of a node object. +type NodeStatus struct { + Conditions clusterv1.Conditions + V1Beta2 *NodeObjectV1Beta2Status +} + +// NodeObjectV1Beta2Status is the v1Beta2 status of a node object. +type NodeObjectV1Beta2Status struct { + Conditions []metav1.Condition +} + +// GetConditions returns the set of conditions for this object. +func (o *NodeObject) GetConditions() clusterv1.Conditions { + return o.Status.Conditions +} + +// SetConditions sets the conditions on this object. +func (o *NodeObject) SetConditions(conditions clusterv1.Conditions) { + o.Status.Conditions = conditions +} + +// GetV1Beta2Conditions returns the set of conditions for this object. +func (o *NodeObject) GetV1Beta2Conditions() []metav1.Condition { + if o.Status.V1Beta2 == nil { + return nil + } + return o.Status.V1Beta2.Conditions +} + +// SetV1Beta2Conditions sets conditions for an API object. +func (o *NodeObject) SetV1Beta2Conditions(conditions []metav1.Condition) { + if o.Status.V1Beta2 == nil && conditions != nil { + o.Status.V1Beta2 = &NodeObjectV1Beta2Status{} + } + o.Status.V1Beta2.Conditions = conditions +} + +// GetUID returns object's UID. +func (o *NodeObject) GetUID() types.UID { + return o.UID +} + +// SetUID sets object's UID. +func (o *NodeObject) SetUID(uid types.UID) { + o.UID = uid +} + +// GetCreationTimestamp returns object's CreationTimestamp. +func (o *NodeObject) GetCreationTimestamp() metav1.Time { + return o.CreationTimestamp +} + +// SetCreationTimestamp sets object's CreationTimestamp. +func (o *NodeObject) SetCreationTimestamp(timestamp metav1.Time) { + o.CreationTimestamp = timestamp +} + +// GetDeletionTimestamp returns object's DeletionTimestamp. +func (o *NodeObject) GetDeletionTimestamp() *metav1.Time { + return o.DeletionTimestamp +} + +// SetDeletionTimestamp sets object's DeletionTimestamp. +func (o *NodeObject) SetDeletionTimestamp(timestamp *metav1.Time) { + o.DeletionTimestamp = timestamp +} + +// GetOwnerReferences returns object's OwnerReferences. +func (o *NodeObject) GetOwnerReferences() []metav1.OwnerReference { + return o.OwnerReferences +} + +// SetOwnerReferences sets object's OwnerReferences. +func (o *NodeObject) SetOwnerReferences(references []metav1.OwnerReference) { + o.OwnerReferences = references +} + +// GetManagedFields returns object's ManagedFields. +func (o *NodeObject) GetManagedFields() []metav1.ManagedFieldsEntry { + return o.ManagedFields +} + +// SetManagedFields sets object's ManagedFields. +func (o *NodeObject) SetManagedFields(managedFields []metav1.ManagedFieldsEntry) { + o.ManagedFields = managedFields +} + +// DeepCopyObject returns a deep copy of the object. +func (o *NodeObject) DeepCopyObject() runtime.Object { + panic("implement me") +} diff --git a/cmd/clusterctl/client/tree/tree.go b/cmd/clusterctl/client/tree/tree.go index 36e037f21aec..51c463d881d6 100644 --- a/cmd/clusterctl/client/tree/tree.go +++ b/cmd/clusterctl/client/tree/tree.go @@ -24,7 +24,6 @@ import ( 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/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -57,6 +56,9 @@ type ObjectTreeOptions struct { // Grouping groups sibling object in case the ready conditions // have the same Status, Severity and Reason Grouping bool + + // V1Beta2 instructs tree to use V1Beta2 conditions. + V1Beta2 bool } // ObjectTree defines an object tree representing the status of a Cluster API cluster. @@ -91,8 +93,20 @@ func (od ObjectTree) Add(parent, obj client.Object, opts ...AddObjectOption) (ad addOpts := &addObjectOptions{} addOpts.ApplyOptions(opts) - objReady := GetReadyCondition(obj) - parentReady := GetReadyCondition(parent) + // Get a small set of conditions that will be used to determine e.g. when grouping or when an object is just an echo of + // its parent. + var objReady, parentReady *clusterv1.Condition + var objAvailableV1Beta2, objReadyV1Beta2, objUpToDateV1Beta2, parentReadyV1Beta2 *metav1.Condition + switch od.options.V1Beta2 { + case true: + objAvailableV1Beta2 = GetAvailableV1Beta2Condition(obj) + objReadyV1Beta2 = GetReadyV1Beta2Condition(obj) + objUpToDateV1Beta2 = GetMachineUpToDateV1Beta2Condition(obj) + parentReadyV1Beta2 = GetReadyV1Beta2Condition(parent) + default: + objReady = GetReadyCondition(obj) + parentReady = GetReadyCondition(parent) + } // If it is requested to show all the conditions for the object, add // the ShowObjectConditionsAnnotation to signal this to the presentation layer. @@ -100,12 +114,18 @@ func (od ObjectTree) Add(parent, obj client.Object, opts ...AddObjectOption) (ad addAnnotation(obj, ShowObjectConditionsAnnotation, "True") } - // If the object should be hidden if the object's ready condition is true ot it has the - // same Status, Severity and Reason of the parent's object ready condition (it is an echo), - // return early. + // If echo should be dropped from the ObjectTree, return if the object's ready condition is true, and it is the same it has of parent's object ready condition (it is an echo). + // Note: the Echo option applies only for infrastructure machine or bootstrap config objects, and for those objects only Ready condition makes sense. if addOpts.NoEcho && !od.options.Echo { - if (objReady != nil && objReady.Status == corev1.ConditionTrue) || hasSameReadyStatusSeverityAndReason(parentReady, objReady) { - return false, false + switch od.options.V1Beta2 { + case true: + if (objReadyV1Beta2 != nil && objReadyV1Beta2.Status == metav1.ConditionTrue) || hasSameAvailableReadyUptoDateStatusAndReason(nil, nil, parentReadyV1Beta2, objReadyV1Beta2, nil, nil) { + return false, false + } + default: + if (objReady != nil && objReady.Status == corev1.ConditionTrue) || hasSameReadyStatusSeverityAndReason(parentReady, objReady) { + return false, false + } } } @@ -131,12 +151,27 @@ func (od ObjectTree) Add(parent, obj client.Object, opts ...AddObjectOption) (ad for i := range siblings { s := siblings[i] - sReady := GetReadyCondition(s) - // If the object's ready condition has a different Status, Severity and Reason than the sibling object, - // move on (they should not be grouped). - if !hasSameReadyStatusSeverityAndReason(objReady, sReady) { - continue + var sReady *clusterv1.Condition + var sAvailableV1Beta2, sReadyV1Beta2, sUpToDateV1Beta2 *metav1.Condition + switch od.options.V1Beta2 { + case true: + // If the object's ready condition has a different Available/ReadyUpToDate condition than the sibling object, + // move on (they should not be grouped). + sAvailableV1Beta2 = GetAvailableV1Beta2Condition(s) + sReadyV1Beta2 = GetReadyV1Beta2Condition(s) + sUpToDateV1Beta2 = GetMachineUpToDateV1Beta2Condition(s) + if !hasSameAvailableReadyUptoDateStatusAndReason(objAvailableV1Beta2, sAvailableV1Beta2, objReadyV1Beta2, sReadyV1Beta2, objUpToDateV1Beta2, sUpToDateV1Beta2) { + continue + } + default: + sReady = GetReadyCondition(s) + + // If the object's ready condition has a different Status, Severity and Reason than the sibling object, + // move on (they should not be grouped). + if !hasSameReadyStatusSeverityAndReason(objReady, sReady) { + continue + } } // If the sibling node is already a group object @@ -144,7 +179,13 @@ func (od ObjectTree) Add(parent, obj client.Object, opts ...AddObjectOption) (ad // Check to see if the group object kind matches the object, i.e. group is MachineGroup and object is Machine. // If so, upgrade it with the current object. if s.GetObjectKind().GroupVersionKind().Kind == obj.GetObjectKind().GroupVersionKind().Kind+"Group" { - updateGroupNode(s, sReady, obj, objReady) + switch od.options.V1Beta2 { + case true: + updateV1Beta2GroupNode(s, sReadyV1Beta2, obj, objAvailableV1Beta2, objReadyV1Beta2, objUpToDateV1Beta2) + default: + updateGroupNode(s, sReady, obj, objReady) + } + return true, false } } else if s.GetObjectKind().GroupVersionKind().Kind != obj.GetObjectKind().GroupVersionKind().Kind { @@ -155,7 +196,14 @@ func (od ObjectTree) Add(parent, obj client.Object, opts ...AddObjectOption) (ad // Otherwise the object and the current sibling should be merged in a group. // Create virtual object for the group and add it to the object tree. - groupNode := createGroupNode(s, sReady, obj, objReady) + var groupNode *NodeObject + switch od.options.V1Beta2 { + case true: + groupNode = createV1Beta2GroupNode(s, sReadyV1Beta2, obj, objAvailableV1Beta2, objReadyV1Beta2, objUpToDateV1Beta2) + default: + groupNode = createGroupNode(s, sReady, obj, objReady) + } + // By default, grouping objects should be sorted last. addAnnotation(groupNode, ObjectZOrderAnnotation, strconv.Itoa(GetZOrder(obj))) @@ -216,6 +264,26 @@ func (od ObjectTree) GetObjectsByParent(id types.UID) []client.Object { return out } +func hasSameAvailableReadyUptoDateStatusAndReason(availableA, availableB, readyA, readyB, upToDateA, upToDateB *metav1.Condition) bool { + if !hasSameStatusAndReason(availableA, availableB) { + return false + } + if !hasSameStatusAndReason(readyA, readyB) { + return false + } + if !hasSameStatusAndReason(upToDateA, upToDateB) { + return false + } + return true +} + +func hasSameStatusAndReason(a, b *metav1.Condition) bool { + if ((a == nil) != (b == nil)) || ((a != nil && b != nil) && (a.Status != b.Status || a.Reason != b.Reason)) { + return false + } + return true +} + func hasSameReadyStatusSeverityAndReason(a, b *clusterv1.Condition) bool { if a == nil && b == nil { return true @@ -229,7 +297,87 @@ func hasSameReadyStatusSeverityAndReason(a, b *clusterv1.Condition) bool { a.Reason == b.Reason } -func createGroupNode(sibling client.Object, siblingReady *clusterv1.Condition, obj client.Object, objReady *clusterv1.Condition) *unstructured.Unstructured { +func createV1Beta2GroupNode(sibling client.Object, siblingReady *metav1.Condition, obj client.Object, objAvailable, objReady, objUpToDate *metav1.Condition) *NodeObject { + kind := fmt.Sprintf("%sGroup", obj.GetObjectKind().GroupVersionKind().Kind) + + // Create a new group node and add the GroupObjectAnnotation to signal + // this to the presentation layer. + // NB. The group nodes gets a unique ID to avoid conflicts. + groupNode := VirtualObject(obj.GetNamespace(), kind, readyStatusReasonUIDV1Beta2(obj)) + addAnnotation(groupNode, GroupObjectAnnotation, "True") + + // Update the list of items included in the group and store it in the GroupItemsAnnotation. + items := []string{obj.GetName(), sibling.GetName()} + sort.Strings(items) + addAnnotation(groupNode, GroupItemsAnnotation, strings.Join(items, GroupItemsSeparator)) + + // Update the group's available condition and counter. + addAnnotation(groupNode, GroupItemsAvailableCounter, "0") + if objAvailable != nil { + objAvailable.LastTransitionTime = metav1.Time{} + objAvailable.Message = "" + setAvailableV1Beta2Condition(groupNode, objAvailable) + if objAvailable.Status == metav1.ConditionTrue { + // When creating a group, it is already the sum of obj and its own sibling, + // and they all have same conditions. + addAnnotation(groupNode, GroupItemsAvailableCounter, "2") + } + } + + // Update the group's ready condition and counter. + addAnnotation(groupNode, GroupItemsReadyCounter, "0") + if objReady != nil { + objReady.LastTransitionTime = minLastTransitionTimeV1Beta2(objReady, siblingReady) + objReady.Message = "" + setReadyV1Beta2Condition(groupNode, objReady) + if objReady.Status == metav1.ConditionTrue { + // When creating a group, it is already the sum of obj and its own sibling, + // and they all have same conditions. + addAnnotation(groupNode, GroupItemsReadyCounter, "2") + } + } + + // Update the group's upToDate condition and counter. + addAnnotation(groupNode, GroupItemsUpToDateCounter, "0") + if objUpToDate != nil { + objUpToDate.LastTransitionTime = metav1.Time{} + objUpToDate.Message = "" + setUpToDateV1Beta2Condition(groupNode, objUpToDate) + if objUpToDate.Status == metav1.ConditionTrue { + // When creating a group, it is already the sum of obj and its own sibling, + // and they all have same conditions. + addAnnotation(groupNode, GroupItemsUpToDateCounter, "2") + } + } + + return groupNode +} + +func readyStatusReasonUIDV1Beta2(obj client.Object) string { + ready := GetReadyV1Beta2Condition(obj) + if ready == nil { + return fmt.Sprintf("zzz_%s", util.RandomString(6)) + } + return fmt.Sprintf("zz_%s_%s_%s", ready.Status, ready.Reason, util.RandomString(6)) +} + +func minLastTransitionTimeV1Beta2(a, b *metav1.Condition) metav1.Time { + if a == nil && b == nil { + return metav1.Time{} + } + if (a != nil) && (b == nil) { + return a.LastTransitionTime + } + if a == nil { + return b.LastTransitionTime + } + if a.LastTransitionTime.Time.After(b.LastTransitionTime.Time) { + return b.LastTransitionTime + } + return a.LastTransitionTime +} + +func createGroupNode(sibling client.Object, siblingReady *clusterv1.Condition, obj client.Object, objReady *clusterv1.Condition) *NodeObject { kind := fmt.Sprintf("%sGroup", obj.GetObjectKind().GroupVersionKind().Kind) // Create a new group node and add the GroupObjectAnnotation to signal @@ -267,7 +415,7 @@ func minLastTransitionTime(a, b *clusterv1.Condition) metav1.Time { if (a != nil) && (b == nil) { return a.LastTransitionTime } - if (a == nil) && (b != nil) { + if a == nil { return b.LastTransitionTime } if a.LastTransitionTime.Time.After(b.LastTransitionTime.Time) { @@ -276,6 +424,42 @@ func minLastTransitionTime(a, b *clusterv1.Condition) metav1.Time { return a.LastTransitionTime } +func updateV1Beta2GroupNode(groupObj client.Object, groupReady *metav1.Condition, obj client.Object, objAvailable, objReady, objUpToDate *metav1.Condition) { + // Update the list of items included in the group and store it in the GroupItemsAnnotation. + items := strings.Split(GetGroupItems(groupObj), GroupItemsSeparator) + items = append(items, obj.GetName()) + sort.Strings(items) + addAnnotation(groupObj, GroupItemsAnnotation, strings.Join(items, GroupItemsSeparator)) + + // Update the group's available counter. + if objAvailable != nil { + if objAvailable.Status == metav1.ConditionTrue { + availableCounter := GetGroupItemsAvailableCounter(groupObj) + addAnnotation(groupObj, GroupItemsAvailableCounter, fmt.Sprintf("%d", availableCounter+1)) + } + } + + // Update the group's ready condition and ready counter. + if groupReady != nil { + groupReady.LastTransitionTime = minLastTransitionTimeV1Beta2(objReady, groupReady) + groupReady.Message = "" + setReadyV1Beta2Condition(groupObj, groupReady) + } + + if objReady != nil && objReady.Status == metav1.ConditionTrue { + readyCounter := GetGroupItemsReadyCounter(groupObj) + addAnnotation(groupObj, GroupItemsReadyCounter, fmt.Sprintf("%d", readyCounter+1)) + } + + // Update the group's upToDate counter. + if objUpToDate != nil { + if objUpToDate.Status == metav1.ConditionTrue { + upToDateCounter := GetGroupItemsUpToDateCounter(groupObj) + addAnnotation(groupObj, GroupItemsUpToDateCounter, fmt.Sprintf("%d", upToDateCounter+1)) + } + } +} + func updateGroupNode(groupObj client.Object, groupReady *clusterv1.Condition, obj client.Object, objReady *clusterv1.Condition) { // Update the list of items included in the group and store it in the GroupItemsAnnotation. items := strings.Split(GetGroupItems(groupObj), GroupItemsSeparator) @@ -295,7 +479,7 @@ func isObjDebug(obj client.Object, debugFilter string) bool { if debugFilter == "" { return false } - for _, filter := range strings.Split(debugFilter, ",") { + for _, filter := range strings.Split(strings.ToLower(debugFilter), ",") { filter = strings.TrimSpace(filter) if filter == "" { continue @@ -305,12 +489,12 @@ func isObjDebug(obj client.Object, debugFilter string) bool { } kn := strings.Split(filter, "/") if len(kn) == 2 { - if obj.GetObjectKind().GroupVersionKind().Kind == kn[0] && obj.GetName() == kn[1] { + if strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) == kn[0] && obj.GetName() == kn[1] { return true } continue } - if obj.GetObjectKind().GroupVersionKind().Kind == kn[0] { + if strings.ToLower(obj.GetObjectKind().GroupVersionKind().Kind) == kn[0] { return true } } diff --git a/cmd/clusterctl/client/tree/tree_test.go b/cmd/clusterctl/client/tree/tree_test.go index 9b3253958d58..efacd9963a79 100644 --- a/cmd/clusterctl/client/tree/tree_test.go +++ b/cmd/clusterctl/client/tree/tree_test.go @@ -17,20 +17,235 @@ limitations under the License. package tree import ( + "fmt" "strings" "testing" "time" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/util/conditions" + v1beta2conditions "sigs.k8s.io/cluster-api/util/conditions/v1beta2" ) +func Test_hasSameAvailableReadyUptoDateStatusAndReason(t *testing.T) { + conditionTrue := &metav1.Condition{Status: metav1.ConditionTrue} + conditionFalse := &metav1.Condition{Status: metav1.ConditionFalse, Reason: "Reason", Message: "message false"} + conditionFalseAnotherReason := &metav1.Condition{Status: metav1.ConditionFalse, Reason: "AnotherReason", Message: "message false"} + + type conditionPair struct { + a *metav1.Condition + b *metav1.Condition + } + tests := []struct { + name string + args map[string]conditionPair + want bool + }{ + { + name: "Objects without conditions are the same", + args: map[string]conditionPair{ + "available": {a: nil, b: nil}, + "ready": {a: nil, b: nil}, + "up-tp-date": {a: nil, b: nil}, + }, + want: true, + }, + { + name: "Objects with same Available condition are the same", + args: map[string]conditionPair{ + "available": { + a: conditionTrue, + b: conditionTrue, + }, + "ready": {a: nil, b: nil}, + "up-tp-date": {a: nil, b: nil}, + }, + want: true, + }, + { + name: "Objects with different Available.Status are not the same", + args: map[string]conditionPair{ + "available": { + a: conditionTrue, + b: conditionFalse, + }, + "ready": {a: nil, b: nil}, + "up-tp-date": {a: nil, b: nil}, + }, + want: false, + }, + { + name: "Objects with different Available.Reason are not the same", + args: map[string]conditionPair{ + "available": { + a: conditionFalse, + b: conditionFalseAnotherReason, + }, + "ready": {a: nil, b: nil}, + "up-tp-date": {a: nil, b: nil}, + }, + want: false, + }, + { + name: "Objects with same Ready condition are the same", + args: map[string]conditionPair{ + "available": {a: nil, b: nil}, + "ready": { + a: conditionTrue, + b: conditionTrue, + }, + "up-tp-date": {a: nil, b: nil}, + }, + want: true, + }, + { + name: "Objects with different Ready.Status are not the same", + args: map[string]conditionPair{ + "available": {a: nil, b: nil}, + "ready": { + a: conditionTrue, + b: conditionFalse, + }, + "up-tp-date": {a: nil, b: nil}, + }, + want: false, + }, + { + name: "Objects with different Ready.Reason are not the same", + args: map[string]conditionPair{ + "available": {a: nil, b: nil}, + "ready": { + a: conditionFalse, + b: conditionFalseAnotherReason, + }, + "up-tp-date": {a: nil, b: nil}, + }, + want: false, + }, + { + name: "Objects with same UpToDate condition are the same", + args: map[string]conditionPair{ + "available": {a: nil, b: nil}, + "ready": {a: nil, b: nil}, + "up-tp-date": { + a: conditionTrue, + b: conditionTrue, + }, + }, + want: true, + }, + { + name: "Objects with different UpToDate.Status are not the same", + args: map[string]conditionPair{ + "available": {a: nil, b: nil}, + "ready": {a: nil, b: nil}, + "up-tp-date": { + a: conditionTrue, + b: conditionFalse, + }, + }, + want: false, + }, + { + name: "Objects with different UpToDate.Reason are not the same", + args: map[string]conditionPair{ + "available": {a: nil, b: nil}, + "ready": {a: nil, b: nil}, + "up-tp-date": { + a: conditionFalse, + b: conditionFalseAnotherReason, + }, + }, + want: false, + }, + { + name: "Objects with same conditions are the same", + args: map[string]conditionPair{ + "available": { + a: conditionTrue, + b: conditionTrue, + }, + "ready": { + a: conditionTrue, + b: conditionTrue, + }, + "up-tp-date": { + a: conditionTrue, + b: conditionTrue, + }, + }, + want: true, + }, + { + name: "Objects with at least one condition different are not the same", + args: map[string]conditionPair{ + "available": { + a: conditionFalse, + b: conditionTrue, + }, + "ready": { + a: conditionTrue, + b: conditionTrue, + }, + "up-tp-date": { + a: conditionTrue, + b: conditionTrue, + }, + }, + want: false, + }, + { + name: "Objects with at least one condition different are not the same", + args: map[string]conditionPair{ + "available": { + a: conditionTrue, + b: conditionTrue, + }, + "ready": { + a: conditionFalse, + b: conditionTrue, + }, + "up-tp-date": { + a: conditionTrue, + b: conditionTrue, + }, + }, + want: false, + }, + { + name: "Objects with at least one condition different are not the same", + args: map[string]conditionPair{ + "available": { + a: conditionTrue, + b: conditionTrue, + }, + "ready": { + a: conditionTrue, + b: conditionTrue, + }, + "up-tp-date": { + a: conditionFalse, + b: conditionTrue, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := hasSameAvailableReadyUptoDateStatusAndReason(tt.args["available"].a, tt.args["available"].b, tt.args["ready"].a, tt.args["ready"].b, tt.args["up-tp-date"].a, tt.args["up-tp-date"].b) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + func Test_hasSameReadyStatusSeverityAndReason(t *testing.T) { readyTrue := conditions.TrueCondition(clusterv1.ReadyCondition) readyFalseReasonInfo := conditions.FalseCondition(clusterv1.ReadyCondition, "Reason", clusterv1.ConditionSeverityInfo, "message falseInfo1") @@ -97,6 +312,69 @@ func Test_hasSameReadyStatusSeverityAndReason(t *testing.T) { } } +func Test_minLastTransitionTimeV1Beta2(t *testing.T) { + now := &metav1.Condition{Type: "now", LastTransitionTime: metav1.Now()} + beforeNow := &metav1.Condition{Type: "beforeNow", LastTransitionTime: metav1.Time{Time: now.LastTransitionTime.Time.Add(-1 * time.Hour)}} + type args struct { + a *metav1.Condition + b *metav1.Condition + } + tests := []struct { + name string + args args + want metav1.Time + }{ + { + name: "nil, nil should return empty time", + args: args{ + a: nil, + b: nil, + }, + want: metav1.Time{}, + }, + { + name: "nil, now should return now", + args: args{ + a: nil, + b: now, + }, + want: now.LastTransitionTime, + }, + { + name: "now, nil should return now", + args: args{ + a: now, + b: nil, + }, + want: now.LastTransitionTime, + }, + { + name: "now, beforeNow should return beforeNow", + args: args{ + a: now, + b: beforeNow, + }, + want: beforeNow.LastTransitionTime, + }, + { + name: "beforeNow, now should return beforeNow", + args: args{ + a: now, + b: beforeNow, + }, + want: beforeNow.LastTransitionTime, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := minLastTransitionTimeV1Beta2(tt.args.a, tt.args.b) + g.Expect(got.Time).To(BeTemporally("~", tt.want.Time)) + }) + } +} + func Test_minLastTransitionTime(t *testing.T) { now := &clusterv1.Condition{Type: "now", LastTransitionTime: metav1.Now()} beforeNow := &clusterv1.Condition{Type: "beforeNow", LastTransitionTime: metav1.Time{Time: now.LastTransitionTime.Time.Add(-1 * time.Hour)}} @@ -223,6 +501,91 @@ func Test_isObjDebug(t *testing.T) { } } +func Test_createV1Beta2GroupNode(t *testing.T) { + now := metav1.Now() + beforeNow := metav1.Time{Time: now.Time.Add(-1 * time.Hour)}.Rfc3339Copy() + + obj := &clusterv1.Machine{ + TypeMeta: metav1.TypeMeta{ + Kind: "Machine", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "my-machine", + }, + Status: clusterv1.MachineStatus{ + V1Beta2: &clusterv1.MachineV1Beta2Status{ + Conditions: []metav1.Condition{ + {Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue}, + {Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue, LastTransitionTime: now}, + {Type: clusterv1.MachineUpToDateV1Beta2Condition, Status: metav1.ConditionFalse}, + }, + }, + }, + } + + sibling := &clusterv1.Machine{ + TypeMeta: metav1.TypeMeta{ + Kind: "Machine", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "sibling-machine", + }, + Status: clusterv1.MachineStatus{ + V1Beta2: &clusterv1.MachineV1Beta2Status{ + Conditions: []metav1.Condition{ + {Type: clusterv1.ReadyV1Beta2Condition, LastTransitionTime: beforeNow}, + }, + }, + }, + } + + want := &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: "MachineGroup", + APIVersion: "virtual.cluster.x-k8s.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "", // random string + Namespace: "ns", + Annotations: map[string]string{ + VirtualObjectAnnotation: "True", + GroupObjectAnnotation: "True", + GroupItemsAnnotation: "my-machine, sibling-machine", + GroupItemsReadyCounter: "2", + GroupItemsAvailableCounter: "2", + GroupItemsUpToDateCounter: "0", + }, + UID: types.UID(""), // random string + }, + Status: NodeStatus{ + V1Beta2: &NodeObjectV1Beta2Status{ + Conditions: []metav1.Condition{ + {Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue}, + {Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue, LastTransitionTime: beforeNow}, + {Type: clusterv1.MachineUpToDateV1Beta2Condition, Status: metav1.ConditionFalse}, + }, + }, + }, + } + + g := NewWithT(t) + got := createV1Beta2GroupNode(sibling, GetReadyV1Beta2Condition(sibling), obj, GetAvailableV1Beta2Condition(obj), GetReadyV1Beta2Condition(obj), GetMachineUpToDateV1Beta2Condition(obj)) + + // Some values are generated randomly, so pick up them. + want.SetName(got.GetName()) + want.SetUID(got.GetUID()) + for i := range got.Status.V1Beta2.Conditions { + if got.Status.V1Beta2.Conditions[i].Type == clusterv1.ReadyV1Beta2Condition { + continue + } + got.Status.V1Beta2.Conditions[i].LastTransitionTime = metav1.Time{} + } + + g.Expect(got).To(BeComparableTo(want)) +} + func Test_createGroupNode(t *testing.T) { now := metav1.Now() beforeNow := metav1.Time{Time: now.Time.Add(-1 * time.Hour)} @@ -257,27 +620,27 @@ func Test_createGroupNode(t *testing.T) { }, } - want := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "virtual.cluster.x-k8s.io/v1beta1", - "kind": "MachineGroup", - "metadata": map[string]interface{}{ - "namespace": "ns", - "name": "", // random string - "annotations": map[string]interface{}{ - VirtualObjectAnnotation: "True", - GroupObjectAnnotation: "True", - GroupItemsAnnotation: "my-machine, sibling-machine", - }, - "uid": "", // random string + want := &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: "MachineGroup", + APIVersion: "virtual.cluster.x-k8s.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "", // random string + Namespace: "ns", + Annotations: map[string]string{ + VirtualObjectAnnotation: "True", + GroupObjectAnnotation: "True", + GroupItemsAnnotation: "my-machine, sibling-machine", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ - "status": "", - "lastTransitionTime": beforeNow.Time.UTC().Format(time.RFC3339), - "type": "Ready", - }, + UID: types.UID(""), // random string + }, + Status: NodeStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "Ready", + Status: "", + LastTransitionTime: beforeNow, }, }, }, @@ -293,31 +656,118 @@ func Test_createGroupNode(t *testing.T) { g.Expect(got).To(BeComparableTo(want)) } -func Test_updateGroupNode(t *testing.T) { +func Test_updateV1Beta2GroupNode(t *testing.T) { now := metav1.Now() beforeNow := metav1.Time{Time: now.Time.Add(-1 * time.Hour)} - group := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "virtual.cluster.x-k8s.io/v1beta1", - "kind": "MachineGroup", - "metadata": map[string]interface{}{ - "namespace": "ns", - "name": "random-name", - "annotations": map[string]interface{}{ - VirtualObjectAnnotation: "True", - GroupObjectAnnotation: "True", - GroupItemsAnnotation: "my-machine, sibling-machine", + group := &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: "MachineGroup", + APIVersion: "virtual.cluster.x-k8s.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "", // random string + Namespace: "ns", + Annotations: map[string]string{ + VirtualObjectAnnotation: "True", + GroupObjectAnnotation: "True", + GroupItemsAnnotation: "my-machine, sibling-machine", + GroupItemsReadyCounter: "2", + GroupItemsAvailableCounter: "2", + GroupItemsUpToDateCounter: "0", + }, + UID: types.UID(""), // random string + }, + Status: NodeStatus{ + V1Beta2: &NodeObjectV1Beta2Status{ + Conditions: []metav1.Condition{ + {Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue}, + {Type: clusterv1.ReadyV1Beta2Condition, LastTransitionTime: beforeNow}, + {Type: clusterv1.MachineUpToDateV1Beta2Condition, Status: metav1.ConditionFalse}, }, - "uid": "random-uid", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ - "status": "", - "lastTransitionTime": beforeNow.Time.UTC().Format(time.RFC3339), - "type": "Ready", - }, + }, + } + + obj := &clusterv1.Machine{ + TypeMeta: metav1.TypeMeta{ + Kind: "Machine", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "another-machine", + }, + Status: clusterv1.MachineStatus{ + V1Beta2: &clusterv1.MachineV1Beta2Status{ + Conditions: []metav1.Condition{ + {Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue}, + {Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue, LastTransitionTime: now}, + {Type: clusterv1.MachineUpToDateV1Beta2Condition, Status: metav1.ConditionFalse}, + }, + }, + }, + } + + want := &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: "MachineGroup", + APIVersion: "virtual.cluster.x-k8s.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "", // random string + Namespace: "ns", + Annotations: map[string]string{ + VirtualObjectAnnotation: "True", + GroupObjectAnnotation: "True", + GroupItemsAnnotation: "another-machine, my-machine, sibling-machine", + GroupItemsReadyCounter: "3", + GroupItemsAvailableCounter: "3", + GroupItemsUpToDateCounter: "0", + }, + UID: types.UID(""), // random string + }, + Status: NodeStatus{ + V1Beta2: &NodeObjectV1Beta2Status{ + Conditions: []metav1.Condition{ + {Type: clusterv1.AvailableV1Beta2Condition, Status: metav1.ConditionTrue}, + {Type: clusterv1.ReadyV1Beta2Condition, LastTransitionTime: beforeNow}, + {Type: clusterv1.MachineUpToDateV1Beta2Condition, Status: metav1.ConditionFalse}, + }, + }, + }, + } + + g := NewWithT(t) + updateV1Beta2GroupNode(group, GetReadyV1Beta2Condition(group), obj, GetAvailableV1Beta2Condition(obj), GetReadyV1Beta2Condition(obj), GetMachineUpToDateV1Beta2Condition(obj)) + + g.Expect(group).To(BeComparableTo(want)) +} + +func Test_updateGroupNode(t *testing.T) { + now := metav1.Now() + beforeNow := metav1.Time{Time: now.Time.Add(-1 * time.Hour)} + + group := &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: "MachineGroup", + APIVersion: "virtual.cluster.x-k8s.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "", // random string + Namespace: "ns", + Annotations: map[string]string{ + VirtualObjectAnnotation: "True", + GroupObjectAnnotation: "True", + GroupItemsAnnotation: "my-machine, sibling-machine", + }, + UID: types.UID(""), // random string + }, + Status: NodeStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "Ready", + Status: "", + LastTransitionTime: beforeNow, }, }, }, @@ -338,27 +788,27 @@ func Test_updateGroupNode(t *testing.T) { }, } - want := &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": "virtual.cluster.x-k8s.io/v1beta1", - "kind": "MachineGroup", - "metadata": map[string]interface{}{ - "namespace": "ns", - "name": "random-name", - "annotations": map[string]interface{}{ - VirtualObjectAnnotation: "True", - GroupObjectAnnotation: "True", - GroupItemsAnnotation: "another-machine, my-machine, sibling-machine", - }, - "uid": "random-uid", + want := &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: "MachineGroup", + APIVersion: "virtual.cluster.x-k8s.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "", // random string + Namespace: "ns", + Annotations: map[string]string{ + VirtualObjectAnnotation: "True", + GroupObjectAnnotation: "True", + GroupItemsAnnotation: "another-machine, my-machine, sibling-machine", }, - "status": map[string]interface{}{ - "conditions": []interface{}{ - map[string]interface{}{ - "status": "", - "lastTransitionTime": beforeNow.Time.UTC().Format(time.RFC3339), - "type": "Ready", - }, + UID: types.UID(""), // random string + }, + Status: NodeStatus{ + Conditions: clusterv1.Conditions{ + { + Type: "Ready", + Status: "", + LastTransitionTime: beforeNow, }, }, }, @@ -398,25 +848,29 @@ func Test_Add_setsShowObjectConditionsAnnotation(t *testing.T) { }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - root := parent.DeepCopy() - tree := NewObjectTree(root, tt.args.treeOptions) + for _, v1beta2 := range []bool{true, false} { + tt.args.treeOptions.V1Beta2 = v1beta2 - g := NewWithT(t) - getAdded, gotVisible := tree.Add(root, obj.DeepCopy()) - g.Expect(getAdded).To(BeTrue()) - g.Expect(gotVisible).To(BeTrue()) + t.Run(tt.name+" v1beta2: "+fmt.Sprintf("%t", v1beta2), func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, tt.args.treeOptions) - gotObj := tree.GetObject("my-machine") - g.Expect(gotObj).ToNot(BeNil()) - switch tt.want { - case true: - g.Expect(gotObj.GetAnnotations()).To(HaveKey(ShowObjectConditionsAnnotation)) - g.Expect(gotObj.GetAnnotations()[ShowObjectConditionsAnnotation]).To(Equal("True")) - case false: - g.Expect(gotObj.GetAnnotations()).ToNot(HaveKey(ShowObjectConditionsAnnotation)) - } - }) + g := NewWithT(t) + getAdded, gotVisible := tree.Add(root, obj.DeepCopy()) + g.Expect(getAdded).To(BeTrue()) + g.Expect(gotVisible).To(BeTrue()) + + gotObj := tree.GetObject("my-machine") + g.Expect(gotObj).ToNot(BeNil()) + switch tt.want { + case true: + g.Expect(gotObj.GetAnnotations()).To(HaveKey(ShowObjectConditionsAnnotation)) + g.Expect(gotObj.GetAnnotations()[ShowObjectConditionsAnnotation]).To(Equal("True")) + case false: + g.Expect(gotObj.GetAnnotations()).ToNot(HaveKey(ShowObjectConditionsAnnotation)) + } + }) + } } } @@ -459,25 +913,29 @@ func Test_Add_setsGroupingObjectAnnotation(t *testing.T) { }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - root := parent.DeepCopy() - tree := NewObjectTree(root, tt.args.treeOptions) + for _, v1beta2 := range []bool{true, false} { + tt.args.treeOptions.V1Beta2 = v1beta2 - g := NewWithT(t) - getAdded, gotVisible := tree.Add(root, obj.DeepCopy(), tt.args.addOptions...) - g.Expect(getAdded).To(BeTrue()) - g.Expect(gotVisible).To(BeTrue()) + t.Run(tt.name+" v1beta2: "+fmt.Sprintf("%t", v1beta2), func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, tt.args.treeOptions) - gotObj := tree.GetObject("my-machine") - g.Expect(gotObj).ToNot(BeNil()) - switch tt.want { - case true: - g.Expect(gotObj.GetAnnotations()).To(HaveKey(GroupingObjectAnnotation)) - g.Expect(gotObj.GetAnnotations()[GroupingObjectAnnotation]).To(Equal("True")) - case false: - g.Expect(gotObj.GetAnnotations()).ToNot(HaveKey(GroupingObjectAnnotation)) - } - }) + g := NewWithT(t) + getAdded, gotVisible := tree.Add(root, obj.DeepCopy(), tt.args.addOptions...) + g.Expect(getAdded).To(BeTrue()) + g.Expect(gotVisible).To(BeTrue()) + + gotObj := tree.GetObject("my-machine") + g.Expect(gotObj).ToNot(BeNil()) + switch tt.want { + case true: + g.Expect(gotObj.GetAnnotations()).To(HaveKey(GroupingObjectAnnotation)) + g.Expect(gotObj.GetAnnotations()[GroupingObjectAnnotation]).To(Equal("True")) + case false: + g.Expect(gotObj.GetAnnotations()).ToNot(HaveKey(GroupingObjectAnnotation)) + } + }) + } } } @@ -508,24 +966,110 @@ func Test_Add_setsObjectMetaNameAnnotation(t *testing.T) { want: true, }, } + for _, tt := range tests { + for _, v1beta2 := range []bool{true, false} { + treeOptions := ObjectTreeOptions{V1Beta2: v1beta2} + + t.Run(tt.name+" v1beta2: "+fmt.Sprintf("%t", v1beta2), func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, treeOptions) + + g := NewWithT(t) + getAdded, gotVisible := tree.Add(root, obj.DeepCopy(), tt.args.addOptions...) + g.Expect(getAdded).To(BeTrue()) + g.Expect(gotVisible).To(BeTrue()) + + gotObj := tree.GetObject("my-machine") + g.Expect(gotObj).ToNot(BeNil()) + switch tt.want { + case true: + g.Expect(gotObj.GetAnnotations()).To(HaveKey(ObjectMetaNameAnnotation)) + g.Expect(gotObj.GetAnnotations()[ObjectMetaNameAnnotation]).To(Equal("MetaName")) + case false: + g.Expect(gotObj.GetAnnotations()).ToNot(HaveKey(ObjectMetaNameAnnotation)) + } + }) + } + } +} + +func Test_Add_NoEcho_v1Beta2(t *testing.T) { + parent := fakeCluster("parent", + withClusterV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ) + + type args struct { + treeOptions ObjectTreeOptions + addOptions []AddObjectOption + obj *clusterv1.Machine + } + tests := []struct { + name string + args args + wantNode bool + }{ + { + name: "should always add if NoEcho option is not present", + args: args{ + treeOptions: ObjectTreeOptions{}, + addOptions: nil, + obj: fakeMachine("my-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + wantNode: true, + }, + { + name: "should not add if NoEcho option is present and objects have same ReadyCondition", + args: args{ + treeOptions: ObjectTreeOptions{}, + addOptions: []AddObjectOption{NoEcho(true)}, + obj: fakeMachine("my-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + wantNode: false, + }, + { + name: "should add if NoEcho option is present but objects have not same ReadyCondition", + args: args{ + treeOptions: ObjectTreeOptions{}, + addOptions: []AddObjectOption{NoEcho(true)}, + obj: fakeMachine("my-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionFalse}), + ), + }, + wantNode: true, + }, + { + name: "should add if NoEcho option is present, objects have same ReadyCondition, but NoEcho is disabled", + args: args{ + treeOptions: ObjectTreeOptions{Echo: true}, + addOptions: []AddObjectOption{NoEcho(true)}, + obj: fakeMachine("my-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + wantNode: true, + }, + } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + tt.args.treeOptions.V1Beta2 = true root := parent.DeepCopy() - tree := NewObjectTree(root, ObjectTreeOptions{}) + tree := NewObjectTree(root, tt.args.treeOptions) g := NewWithT(t) - getAdded, gotVisible := tree.Add(root, obj.DeepCopy(), tt.args.addOptions...) - g.Expect(getAdded).To(BeTrue()) - g.Expect(gotVisible).To(BeTrue()) + getAdded, gotVisible := tree.Add(root, tt.args.obj, tt.args.addOptions...) + g.Expect(getAdded).To(Equal(tt.wantNode)) + g.Expect(gotVisible).To(Equal(tt.wantNode)) gotObj := tree.GetObject("my-machine") - g.Expect(gotObj).ToNot(BeNil()) - switch tt.want { + switch tt.wantNode { case true: - g.Expect(gotObj.GetAnnotations()).To(HaveKey(ObjectMetaNameAnnotation)) - g.Expect(gotObj.GetAnnotations()[ObjectMetaNameAnnotation]).To(Equal("MetaName")) + g.Expect(gotObj).ToNot(BeNil()) case false: - g.Expect(gotObj.GetAnnotations()).ToNot(HaveKey(ObjectMetaNameAnnotation)) + g.Expect(gotObj).To(BeNil()) } }) } @@ -612,6 +1156,118 @@ func Test_Add_NoEcho(t *testing.T) { } } +func Test_Add_Grouping_v1Beta2(t *testing.T) { + parent := fakeCluster("parent", + withClusterAnnotation(GroupingObjectAnnotation, "True"), + ) + + type args struct { + addOptions []AddObjectOption + siblings []client.Object + obj client.Object + } + tests := []struct { + name string + args args + wantNodesPrefix []string + wantVisible bool + wantItems string + }{ + { + name: "should never group the first child object", + args: args{ + obj: fakeMachine("my-machine"), + }, + wantNodesPrefix: []string{"my-machine"}, + wantVisible: true, + }, + { + name: "should group child node if it has same kind and conditions of an existing one", + args: args{ + siblings: []client.Object{ + fakeMachine("first-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + obj: fakeMachine("second-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + wantNodesPrefix: []string{"zz_True"}, + wantVisible: false, + wantItems: "first-machine, second-machine", + }, + { + name: "should group child node if it has same kind and conditions of an existing group", + args: args{ + siblings: []client.Object{ + fakeMachine("first-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + fakeMachine("second-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + obj: fakeMachine("third-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + wantNodesPrefix: []string{"zz_True"}, + wantVisible: false, + wantItems: "first-machine, second-machine, third-machine", + }, + { + name: "should not group child node if it has different kind", + args: args{ + siblings: []client.Object{ + fakeMachine("first-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + fakeMachine("second-machine", + withMachineV1Beta2Condition(metav1.Condition{Type: clusterv1.ReadyV1Beta2Condition, Status: metav1.ConditionTrue}), + ), + }, + obj: VirtualObject("ns", "NotAMachine", "other-object"), + }, + wantNodesPrefix: []string{"zz_True", "other-object"}, + wantVisible: true, + wantItems: "first-machine, second-machine", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, ObjectTreeOptions{V1Beta2: true}) + + for i := range tt.args.siblings { + tree.Add(parent, tt.args.siblings[i], tt.args.addOptions...) + } + + g := NewWithT(t) + getAdded, gotVisible := tree.Add(root, tt.args.obj, tt.args.addOptions...) + g.Expect(getAdded).To(BeTrue()) + g.Expect(gotVisible).To(Equal(tt.wantVisible)) + + gotObjs := tree.GetObjectsByParent("parent") + g.Expect(gotObjs).To(HaveLen(len(tt.wantNodesPrefix))) + for _, obj := range gotObjs { + found := false + for _, prefix := range tt.wantNodesPrefix { + if strings.HasPrefix(obj.GetName(), prefix) { + found = true + break + } + } + g.Expect(found).To(BeTrue(), "Found object with name %q, waiting for one of %s", obj.GetName(), tt.wantNodesPrefix) + + if strings.HasPrefix(obj.GetName(), "zz_") { + g.Expect(GetGroupItems(obj)).To(Equal(tt.wantItems)) + } + } + }) + } +} + func Test_Add_Grouping(t *testing.T) { parent := fakeCluster("parent", withClusterAnnotation(GroupingObjectAnnotation, "True"), @@ -758,6 +1414,12 @@ func withClusterCondition(c *clusterv1.Condition) func(*clusterv1.Cluster) { } } +func withClusterV1Beta2Condition(c metav1.Condition) func(*clusterv1.Cluster) { + return func(m *clusterv1.Cluster) { + v1beta2conditions.Set(m, c) + } +} + type machineOption func(*clusterv1.Machine) func fakeMachine(name string, options ...machineOption) *clusterv1.Machine { @@ -782,3 +1444,9 @@ func withMachineCondition(c *clusterv1.Condition) func(*clusterv1.Machine) { conditions.Set(m, c) } } + +func withMachineV1Beta2Condition(c metav1.Condition) func(*clusterv1.Machine) { + return func(m *clusterv1.Machine) { + v1beta2conditions.Set(m, c) + } +} diff --git a/cmd/clusterctl/client/tree/util.go b/cmd/clusterctl/client/tree/util.go index d2dec36ea170..936037c6e92b 100644 --- a/cmd/clusterctl/client/tree/util.go +++ b/cmd/clusterctl/client/tree/util.go @@ -21,13 +21,59 @@ import ( "sort" 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/types" "sigs.k8s.io/controller-runtime/pkg/client" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/util/conditions" + v1beta2conditions "sigs.k8s.io/cluster-api/util/conditions/v1beta2" ) +// GetReadyV1Beta2Condition returns the ReadyCondition for an object, if defined. +func GetReadyV1Beta2Condition(obj client.Object) *metav1.Condition { + if getter, ok := obj.(v1beta2conditions.Getter); ok { + return v1beta2conditions.Get(getter, clusterv1.ReadyV1Beta2Condition) + } + + if objUnstructured, ok := obj.(*unstructured.Unstructured); ok { + c, err := v1beta2conditions.UnstructuredGet(objUnstructured, clusterv1.ReadyV1Beta2Condition) + if err != nil { + return nil + } + return c + } + + return nil +} + +// GetAvailableV1Beta2Condition returns the AvailableCondition for an object, if defined. +func GetAvailableV1Beta2Condition(obj client.Object) *metav1.Condition { + if getter, ok := obj.(v1beta2conditions.Getter); ok { + return v1beta2conditions.Get(getter, clusterv1.AvailableV1Beta2Condition) + } + + if objUnstructured, ok := obj.(*unstructured.Unstructured); ok { + c, err := v1beta2conditions.UnstructuredGet(objUnstructured, clusterv1.AvailableV1Beta2Condition) + if err != nil { + return nil + } + return c + } + + return nil +} + +// GetMachineUpToDateV1Beta2Condition returns machine's UpToDate condition, if defined. +// Note: The UpToDate condition only exist on machines, so no need to support reading from unstructured. +func GetMachineUpToDateV1Beta2Condition(obj client.Object) *metav1.Condition { + if getter, ok := obj.(v1beta2conditions.Getter); ok { + return v1beta2conditions.Get(getter, clusterv1.MachineUpToDateV1Beta2Condition) + } + return nil +} + // GetReadyCondition returns the ReadyCondition for an object, if defined. func GetReadyCondition(obj client.Object) *clusterv1.Condition { getter := objToGetter(obj) @@ -37,6 +83,23 @@ func GetReadyCondition(obj client.Object) *clusterv1.Condition { return conditions.Get(getter, clusterv1.ReadyCondition) } +// GetAllV1Beta2Conditions returns the other conditions (all the conditions except ready) for an object, if defined. +func GetAllV1Beta2Conditions(obj client.Object) []metav1.Condition { + if getter, ok := obj.(v1beta2conditions.Getter); ok { + return getter.GetV1Beta2Conditions() + } + + if objUnstructured, ok := obj.(*unstructured.Unstructured); ok { + conditionList, err := v1beta2conditions.UnstructuredGetAll(objUnstructured) + if err != nil { + return nil + } + return conditionList + } + + return nil +} + // GetOtherConditions returns the other conditions (all the conditions except ready) for an object, if defined. func GetOtherConditions(obj client.Object) []*clusterv1.Condition { getter := objToGetter(obj) @@ -55,6 +118,24 @@ func GetOtherConditions(obj client.Object) []*clusterv1.Condition { return conditions } +func setAvailableV1Beta2Condition(obj client.Object, available *metav1.Condition) { + if setter, ok := obj.(v1beta2conditions.Setter); ok { + v1beta2conditions.Set(setter, *available) + } +} + +func setReadyV1Beta2Condition(obj client.Object, ready *metav1.Condition) { + if setter, ok := obj.(v1beta2conditions.Setter); ok { + v1beta2conditions.Set(setter, *ready) + } +} + +func setUpToDateV1Beta2Condition(obj client.Object, upToDate *metav1.Condition) { + if setter, ok := obj.(v1beta2conditions.Setter); ok { + v1beta2conditions.Set(setter, *upToDate) + } +} + func setReadyCondition(obj client.Object, ready *clusterv1.Condition) { setter := objToSetter(obj) if setter == nil { @@ -90,35 +171,37 @@ func objToSetter(obj client.Object) conditions.Setter { } // VirtualObject returns a new virtual object. -func VirtualObject(namespace, kind, name string) *unstructured.Unstructured { +func VirtualObject(namespace, kind, name string) *NodeObject { gk := "virtual.cluster.x-k8s.io/v1beta1" - return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": gk, - "kind": kind, - "metadata": map[string]interface{}{ - "namespace": namespace, - "name": name, - "annotations": map[string]interface{}{ - VirtualObjectAnnotation: "True", - }, - "uid": fmt.Sprintf("%s, Kind=%s, %s/%s", gk, kind, namespace, name), + return &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: kind, + APIVersion: "virtual.cluster.x-k8s.io/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + Annotations: map[string]string{ + VirtualObjectAnnotation: "True", }, + UID: types.UID(fmt.Sprintf("%s, Kind=%s, %s/%s", gk, kind, namespace, name)), }, + Status: NodeStatus{}, } } // ObjectReferenceObject returns a new object referenced by the objectRef. -func ObjectReferenceObject(objectRef *corev1.ObjectReference) *unstructured.Unstructured { - return &unstructured.Unstructured{ - Object: map[string]interface{}{ - "apiVersion": objectRef.APIVersion, - "kind": objectRef.Kind, - "metadata": map[string]interface{}{ - "namespace": objectRef.Namespace, - "name": objectRef.Name, - "uid": fmt.Sprintf("%s, Kind=%s, %s/%s", objectRef.APIVersion, objectRef.Kind, objectRef.Namespace, objectRef.Name), - }, +func ObjectReferenceObject(objectRef *corev1.ObjectReference) *NodeObject { + return &NodeObject{ + TypeMeta: metav1.TypeMeta{ + Kind: objectRef.APIVersion, + APIVersion: objectRef.Kind, + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: objectRef.Namespace, + Name: objectRef.Name, + UID: types.UID(fmt.Sprintf("%s, Kind=%s, %s/%s", objectRef.APIVersion, objectRef.Kind, objectRef.Namespace, objectRef.Name)), }, + Status: NodeStatus{}, } } diff --git a/cmd/clusterctl/cmd/describe_cluster.go b/cmd/clusterctl/cmd/describe_cluster.go index 083a4f035dff..b17e994a55e6 100644 --- a/cmd/clusterctl/cmd/describe_cluster.go +++ b/cmd/clusterctl/cmd/describe_cluster.go @@ -20,6 +20,7 @@ import ( "context" "fmt" "os" + "regexp" "sort" "strings" "time" @@ -30,13 +31,18 @@ import ( "github.com/pkg/errors" "github.com/spf13/cobra" 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/util/duration" + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/utils/ptr" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" "sigs.k8s.io/cluster-api/cmd/clusterctl/client" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/tree" "sigs.k8s.io/cluster-api/cmd/clusterctl/cmd/internal/templates" + "sigs.k8s.io/cluster-api/internal/contract" ) const ( @@ -66,6 +72,7 @@ type describeClusterOptions struct { echo bool grouping bool disableGrouping bool + v1beta2 bool color bool } @@ -133,6 +140,8 @@ func init() { "Disable grouping machines when ready condition has the same Status, Severity and Reason.") _ = describeClusterClusterCmd.Flags().MarkDeprecated("disable-grouping", "use --grouping instead.") + describeClusterClusterCmd.Flags().BoolVar(&dc.v1beta2, "v1beta2", false, + "Use V1Beta2 conditions..") describeClusterClusterCmd.Flags().BoolVarP(&dc.color, "color", "c", false, "Enable or disable color output; if not set color is enabled by default only if using tty. The flag is overridden by the NO_COLOR env variable if set.") // completions @@ -166,6 +175,7 @@ func runDescribeCluster(cmd *cobra.Command, name string) error { AddTemplateVirtualNode: true, Echo: dc.echo, Grouping: dc.grouping && !dc.disableGrouping, + V1Beta2: dc.v1beta2, }) if err != nil { return err @@ -175,10 +185,30 @@ func runDescribeCluster(cmd *cobra.Command, name string) error { color.NoColor = !dc.color } - printObjectTree(tree) + switch dc.v1beta2 { + case true: + printObjectTreeV1Beta2(tree) + default: + printObjectTree(tree) + } + return nil } +// printObjectTreeV1Beta2 prints the cluster status to stdout. +func printObjectTreeV1Beta2(tree *tree.ObjectTree) { + // Creates the output table + tbl := tablewriter.NewWriter(os.Stdout) + tbl.SetHeader([]string{"NAME", "REPLICAS", "AVAILABLE", "READY", "UP TO DATE", "STATUS", "REASON", "SINCE", "MESSAGE"}) + + formatTableTreeV1Beta2(tbl) + // Add row for the root object, the cluster, and recursively for all the nodes representing the cluster status. + addObjectRowV1Beta2("", tbl, tree, tree.GetRoot()) + + // Prints the output table + tbl.Render() +} + // printObjectTree prints the cluster status to stdout. func printObjectTree(tree *tree.ObjectTree) { // Creates the output table @@ -193,6 +223,20 @@ func printObjectTree(tree *tree.ObjectTree) { tbl.Render() } +// formats the table with required attributes. +func formatTableTreeV1Beta2(tbl *tablewriter.Table) { + tbl.SetAutoWrapText(false) + tbl.SetHeaderAlignment(tablewriter.ALIGN_LEFT) + tbl.SetAlignment(tablewriter.ALIGN_LEFT) + + tbl.SetCenterSeparator("") + tbl.SetRowSeparator("") + + tbl.SetHeaderLine(false) + tbl.SetTablePadding(" ") + tbl.SetNoWhiteSpace(true) +} + // formats the table with required attributes. func formatTableTree(tbl *tablewriter.Table) { tbl.SetAutoWrapText(false) @@ -209,6 +253,95 @@ func formatTableTree(tbl *tablewriter.Table) { tbl.SetNoWhiteSpace(true) } +// addObjectRowV1Beta2 add a row for a given object, and recursively for all the object's children. +// NOTE: each row name gets a prefix, that generates a tree view like representation. +func addObjectRowV1Beta2(prefix string, tbl *tablewriter.Table, objectTree *tree.ObjectTree, obj ctrlclient.Object) { + // Get a row descriptor for a given object. + // With v1beta2, the return value of this func adapt to the object represented in the line. + rowDescriptor := newV1beta2RowDescriptor(obj) + + // If the object is a group object, override the condition message with the list of objects in the group. e.g machine-1, machine-2, ... + if tree.IsGroupObject(obj) { + items := strings.Split(tree.GetGroupItems(obj), tree.GroupItemsSeparator) + if len(items) <= 2 { + rowDescriptor.message = gray.Sprintf("See %s", strings.Join(items, tree.GroupItemsSeparator)) + } else { + rowDescriptor.message = gray.Sprintf("See %s, ...", strings.Join(items[:2], tree.GroupItemsSeparator)) + } + } + + // Gets the row name for the object. + // NOTE: The object name gets manipulated in order to improve readability. + name := getRowName(obj) + + // If we are going to show all conditions from this object, let's drop the condition picked in the rowDescriptor. + if tree.IsShowConditionsObject(obj) { + rowDescriptor.status = "" + rowDescriptor.reason = "" + rowDescriptor.age = "" + rowDescriptor.message = "" + } + + // Add the row representing the object that includes + // - The row name with the tree view prefix. + // - Replica counters + // - The object's Available, Ready, UpToDate conditions + // - The condition picked in the rowDescriptor. + // Note: if the condition has a multiline message, also add additional rows for each line. + msg := strings.Split(rowDescriptor.message, "\n") + msg0 := "" + if len(msg) >= 1 { + msg0 = msg[0] + } + tbl.Append([]string{ + fmt.Sprintf("%s%s", gray.Sprint(prefix), name), + rowDescriptor.replicas, + rowDescriptor.availableCounters, + rowDescriptor.readyCounters, + rowDescriptor.upToDateCounters, + rowDescriptor.status, + rowDescriptor.reason, + rowDescriptor.age, + msg0}) + + for _, m := range msg[1:] { + tbl.Append([]string{ + getMultilinePrefix(gray.Sprint(prefix)), + "", + "", + "", + "", + "", + "", + "", + m}) + } + + // If it is required to show all the conditions for the object, add a row for each object's conditions. + if tree.IsShowConditionsObject(obj) { + addOtherConditionsV1Beta2(prefix, tbl, objectTree, obj) + } + + // Add a row for each object's children, taking care of updating the tree view prefix. + childrenObj := objectTree.GetObjectsByParent(obj.GetUID()) + + // printBefore returns true if children[i] should be printed before children[j]. Objects are sorted by z-order and + // row name such that objects with higher z-order are printed first, and objects with the same z-order are + // printed in alphabetical order. + printBefore := func(i, j int) bool { + if tree.GetZOrder(childrenObj[i]) == tree.GetZOrder(childrenObj[j]) { + return getRowName(childrenObj[i]) < getRowName(childrenObj[j]) + } + + return tree.GetZOrder(childrenObj[i]) > tree.GetZOrder(childrenObj[j]) + } + sort.Slice(childrenObj, printBefore) + + for i, child := range childrenObj { + addObjectRowV1Beta2(getChildPrefix(prefix, i, len(childrenObj)), tbl, objectTree, child) + } +} + // addObjectRow add a row for a given object, and recursively for all the object's children. // NOTE: each row name gets a prefix, that generates a tree view like representation. func addObjectRow(prefix string, tbl *tablewriter.Table, objectTree *tree.ObjectTree, obj ctrlclient.Object) { @@ -268,6 +401,70 @@ func addObjectRow(prefix string, tbl *tablewriter.Table, objectTree *tree.Object } } +// addAllConditionsV1Beta2 adds a row for each object condition. +func addOtherConditionsV1Beta2(prefix string, tbl *tablewriter.Table, objectTree *tree.ObjectTree, obj ctrlclient.Object) { + // Add a row for each other condition, taking care of updating the tree view prefix. + // In this case the tree prefix get a filler, to indent conditions from objects, and eventually a + // and additional pipe if the object has children that should be presented after the conditions. + filler := strings.Repeat(" ", 10) + childrenPipe := indent + if objectTree.IsObjectWithChild(obj.GetUID()) { + childrenPipe = pipe + } + + negativePolarityConditions := sets.New( + clusterv1.PausedV1Beta2Condition, + clusterv1.DeletingV1Beta2Condition, + clusterv1.RollingOutV1Beta2Condition, + clusterv1.ScalingUpV1Beta2Condition, + clusterv1.ScalingDownV1Beta2Condition, + clusterv1.RemediatingV1Beta2Condition, + ) + + conditions := tree.GetAllV1Beta2Conditions(obj) + for i := range conditions { + condition := conditions[i] + positivePolarity := true + if negativePolarityConditions.Has(condition.Type) { + positivePolarity = false + } + + childPrefix := getChildPrefix(prefix+childrenPipe+filler, i, len(conditions)) + c, status, age, reason, message := v1Beta2ConditionInfo(condition, positivePolarity) + + // Add the row representing each condition. + // Note: if the condition has a multiline message, also add additional rows for each line. + msg := strings.Split(message, "\n") + msg0 := "" + if len(msg) >= 1 { + msg0 = msg[0] + } + tbl.Append([]string{ + fmt.Sprintf("%s%s", gray.Sprint(childPrefix), cyan.Sprint(condition.Type)), + "", + "", + "", + "", + c.Sprint(status), + reason, + age, + msg0}) + + for _, m := range msg[1:] { + tbl.Append([]string{ + gray.Sprint(getMultilinePrefix(childPrefix)), + "", + "", + "", + "", + "", + "", + "", + m}) + } + } +} + // addOtherConditions adds a row for each object condition except the ready condition, // which is already represented on the object's main row. func addOtherConditions(prefix string, tbl *tablewriter.Table, objectTree *tree.ObjectTree, obj ctrlclient.Object) { @@ -313,6 +510,20 @@ func getChildPrefix(currentPrefix string, childIndex, childCount int) string { return nextPrefix + lastElemPrefix } +// getMultilinePrefix return the tree view prefix for a multiline condition. +func getMultilinePrefix(currentPrefix string) string { + // All ├─ should be replaced by |, so all the existing hierarchic dependencies are carried on + if strings.HasSuffix(currentPrefix, firstElemPrefix) { + return strings.TrimSuffix(currentPrefix, firstElemPrefix) + pipe + } + // All └─ should be replaced by " " because we are under the last element of the tree (nothing to carry on) + if strings.HasSuffix(currentPrefix, lastElemPrefix) { + return strings.TrimSuffix(currentPrefix, lastElemPrefix) + } + + return "?" +} + // getRowName returns the object name in the tree view according to following rules: // - group objects are represented as #of objects kind, e.g. 3 Machines... // - other virtual objects are represented using the object name, e.g. Workers, or meta name if provided. @@ -349,6 +560,242 @@ func getRowName(obj ctrlclient.Object) string { return name } +// v1beta2RowDescriptor contains all the info for representing a condition. +type v1beta2RowDescriptor struct { + age string + replicas string + availableCounters string + readyCounters string + upToDateCounters string + status string + reason string + message string +} + +// newV1beta2RowDescriptor returns a v1beta2ConditionDescriptor for the given condition. +// Note: the return value of this func adapt to the object represented in the line. +func newV1beta2RowDescriptor(obj ctrlclient.Object) v1beta2RowDescriptor { + v := v1beta2RowDescriptor{} + switch obj := obj.(type) { + case *clusterv1.Cluster: + // If the object is a cluster, returns all the replica counters (CP and worker replicas are summed for sake of simplicity); + // also, pick the available condition as the condition to show for this object in case not all the conditions are visualized. + if obj.Status.V1Beta2 != nil { + cp := obj.Status.V1Beta2.ControlPlane + if cp == nil { + cp = &clusterv1.ClusterControlPlaneStatus{} + } + w := obj.Status.V1Beta2.Workers + if w == nil { + w = &clusterv1.WorkersStatus{} + } + if cp.DesiredReplicas != nil || w.DesiredReplicas != nil || cp.Replicas != nil || w.Replicas != nil { + v.replicas = fmt.Sprintf("%d/%d", ptr.Deref(cp.Replicas, 0)+ptr.Deref(w.Replicas, 0), ptr.Deref(cp.DesiredReplicas, 0)+ptr.Deref(w.DesiredReplicas, 0)) + } + if cp.AvailableReplicas != nil || w.AvailableReplicas != nil { + v.availableCounters = fmt.Sprintf("%d", ptr.Deref(cp.AvailableReplicas, 0)+ptr.Deref(w.AvailableReplicas, 0)) + } + if cp.ReadyReplicas != nil || w.ReadyReplicas != nil { + v.readyCounters = fmt.Sprintf("%d", ptr.Deref(cp.ReadyReplicas, 0)+ptr.Deref(w.ReadyReplicas, 0)) + } + if cp.UpToDateReplicas != nil || w.UpToDateReplicas != nil { + v.upToDateCounters = fmt.Sprintf("%d", ptr.Deref(cp.UpToDateReplicas, 0)+ptr.Deref(w.UpToDateReplicas, 0)) + } + } + + if available := tree.GetAvailableV1Beta2Condition(obj); available != nil { + availableColor, availableStatus, availableAge, availableReason, availableMessage := v1Beta2ConditionInfo(*available, true) + v.status = availableColor.Sprintf("Available: %s", availableStatus) + v.reason = availableReason + v.age = availableAge + v.message = availableMessage + } + case *clusterv1.MachineDeployment: + // If the object is a MachineDeployment, returns all the replica counters and pick the available condition + // as the condition to show for this object in case not all the conditions are visualized. + if obj.Spec.Replicas != nil { + v.replicas = fmt.Sprintf("%d/%d", *obj.Spec.Replicas, obj.Status.Replicas) + } + if obj.Status.V1Beta2 != nil { + if obj.Status.V1Beta2.ReadyReplicas != nil { + v.availableCounters = fmt.Sprintf("%d", *obj.Status.V1Beta2.AvailableReplicas) + } + if obj.Status.V1Beta2.ReadyReplicas != nil { + v.readyCounters = fmt.Sprintf("%d", *obj.Status.V1Beta2.ReadyReplicas) + } + if obj.Status.V1Beta2.UpToDateReplicas != nil { + v.upToDateCounters = fmt.Sprintf("%d", *obj.Status.V1Beta2.UpToDateReplicas) + } + } + + if available := tree.GetAvailableV1Beta2Condition(obj); available != nil { + availableColor, availableStatus, availableAge, availableReason, availableMessage := v1Beta2ConditionInfo(*available, true) + v.status = availableColor.Sprintf("Available: %s", availableStatus) + v.reason = availableReason + v.age = availableAge + v.message = availableMessage + } + + case *clusterv1.MachineSet: + // If the object is a MachineSet, returns all the replica counters and pick the available condition + // as the condition to show for this object in case not all the conditions are visualized. + if obj.Spec.Replicas != nil { + v.replicas = fmt.Sprintf("%d/%d", *obj.Spec.Replicas, obj.Status.Replicas) + } + if obj.Status.V1Beta2 != nil { + if obj.Status.V1Beta2.ReadyReplicas != nil { + v.availableCounters = fmt.Sprintf("%d", *obj.Status.V1Beta2.AvailableReplicas) + } + if obj.Status.V1Beta2.ReadyReplicas != nil { + v.readyCounters = fmt.Sprintf("%d", *obj.Status.V1Beta2.ReadyReplicas) + } + if obj.Status.V1Beta2.UpToDateReplicas != nil { + v.upToDateCounters = fmt.Sprintf("%d", *obj.Status.V1Beta2.UpToDateReplicas) + } + } + + case *clusterv1.Machine: + // If the object is a Machine, use Available, Ready and UpToDate conditions to infer replica counters; + // additionally pick the ready condition as the condition to show for this object in case not all the conditions are visualized. + v.replicas = "1" + + v.availableCounters = "0" + if available := tree.GetAvailableV1Beta2Condition(obj); available != nil { + if available.Status == metav1.ConditionTrue { + v.availableCounters = "1" + } + } + + v.readyCounters = "0" + if ready := tree.GetReadyV1Beta2Condition(obj); ready != nil { + readyColor, readyStatus, readyAge, readyReason, readyMessage := v1Beta2ConditionInfo(*ready, true) + v.status = readyColor.Sprintf("Ready: %s", readyStatus) + v.reason = readyReason + v.age = readyAge + v.message = readyMessage + if ready.Status == metav1.ConditionTrue { + v.readyCounters = "1" + } + } + + v.upToDateCounters = "0" + if upToDate := tree.GetMachineUpToDateV1Beta2Condition(obj); upToDate != nil { + if upToDate.Status == metav1.ConditionTrue { + v.upToDateCounters = "1" + } + } + + case *unstructured.Unstructured: + // If the object is a Unstructured, pick the ready condition as the condition to show for this object + // in case not all the conditions are visualized. + // Also, if the Unstructured object implements the Cluster API control plane contract, surface + // corresponding replica counters. + if ready := tree.GetReadyV1Beta2Condition(obj); ready != nil { + readyColor, readyStatus, readyAge, readyReason, readyMessage := v1Beta2ConditionInfo(*ready, true) + v.status = readyColor.Sprintf("Ready: %s", readyStatus) + v.reason = readyReason + v.age = readyAge + v.message = readyMessage + } + + if tree.GetObjectContract(obj) == "ControlPlane" { + if current, err := contract.ControlPlane().StatusReplicas().Get(obj); err == nil && current != nil { + if desired, err := contract.ControlPlane().Replicas().Get(obj); err == nil && desired != nil { + v.replicas = fmt.Sprintf("%d/%d", *current, *desired) + } + } + + if c, err := contract.ControlPlane().V1Beta2AvailableReplicas().Get(obj); err == nil && c != nil { + v.availableCounters = fmt.Sprintf("%d", *c) + } + if c, err := contract.ControlPlane().V1Beta2ReadyReplicas().Get(obj); err == nil && c != nil { + v.readyCounters = fmt.Sprintf("%d", *c) + } + if c, err := contract.ControlPlane().V1Beta2UpToDateReplicas().Get(obj); err == nil && c != nil { + v.upToDateCounters = fmt.Sprintf("%d", *c) + } + } + + case *tree.NodeObject: + // If the object represent a group of objects, surface the corresponding replica counters. + // Also, pick the ready condition as the condition to show for this group. + if tree.IsGroupObject(obj) { + v.readyCounters = fmt.Sprintf("%d", tree.GetGroupItemsReadyCounter(obj)) + v.availableCounters = fmt.Sprintf("%d", tree.GetGroupItemsAvailableCounter(obj)) + v.upToDateCounters = fmt.Sprintf("%d", tree.GetGroupItemsUpToDateCounter(obj)) + } + + if ready := tree.GetReadyV1Beta2Condition(obj); ready != nil { + readyColor, readyStatus, readyAge, readyReason, readyMessage := v1Beta2ConditionInfo(*ready, true) + v.status = readyColor.Sprintf("Ready: %s", readyStatus) + v.reason = readyReason + v.age = readyAge + v.message = readyMessage + } + } + + return v +} + +func v1Beta2ConditionInfo(c metav1.Condition, positivePolarity bool) (color *color.Color, status, age, reason, message string) { + switch c.Status { + case metav1.ConditionFalse: + if positivePolarity { + color = red + } else { + color = green + } + case metav1.ConditionUnknown: + color = yellow + case metav1.ConditionTrue: + if positivePolarity { + color = green + } else { + color = red + } + default: + color = gray + } + + status = string(c.Status) + reason = c.Reason + age = duration.HumanDuration(time.Since(c.LastTransitionTime.Time)) + message = formatParagraph(c.Message, 100) + + return +} + +var re = regexp.MustCompile(`[\s]+`) + +// formatParagraph takes a strings and splits it into n lines of maxWidth length. +// If the string contains line breaks, those are preserved. +func formatParagraph(text string, maxWidth int) string { + lines := []string{} + for _, l := range strings.Split(text, "\n") { + tmp := "" + for _, c := range l { + if c == ' ' { + tmp += " " + continue + } + break + } + for _, w := range re.Split(l, -1) { + if len(tmp)+len(w) < maxWidth { + if strings.TrimSpace(tmp) != "" { + tmp += " " + } + tmp += w + continue + } + lines = append(lines, tmp) + tmp = w + } + lines = append(lines, tmp) + } + return strings.Join(lines, "\n") +} + // conditionDescriptor contains all the info for representing a condition. type conditionDescriptor struct { readyColor *color.Color