diff --git a/cmd/clusterctl/client/client.go b/cmd/clusterctl/client/client.go index e44097b6f4c2..13ca7f8847c1 100644 --- a/cmd/clusterctl/client/client.go +++ b/cmd/clusterctl/client/client.go @@ -22,6 +22,7 @@ import ( "sigs.k8s.io/cluster-api/cmd/clusterctl/client/cluster" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/config" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/repository" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/tree" ) // Client is exposes the clusterctl high-level client library. @@ -67,6 +68,9 @@ type Client interface { // variables. ProcessYAML(options ProcessYAMLOptions) (YamlPrinter, error) + // DescribeCluster returns the object tree representing the status of a Cluster API cluster. + DescribeCluster(options DescribeClusterOptions) (*tree.ObjectTree, error) + // Interface for alpha features in clusterctl AlphaClient } diff --git a/cmd/clusterctl/client/client_test.go b/cmd/clusterctl/client/client_test.go index db4bf852e507..f00ec8e21ecd 100644 --- a/cmd/clusterctl/client/client_test.go +++ b/cmd/clusterctl/client/client_test.go @@ -29,6 +29,7 @@ import ( "sigs.k8s.io/cluster-api/cmd/clusterctl/client/cluster" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/config" "sigs.k8s.io/cluster-api/cmd/clusterctl/client/repository" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/tree" yaml "sigs.k8s.io/cluster-api/cmd/clusterctl/client/yamlprocessor" "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/scheme" "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test" @@ -122,6 +123,10 @@ func (f fakeClient) RolloutRestart(options RolloutRestartOptions) error { return f.internalClient.RolloutRestart(options) } +func (f fakeClient) DescribeCluster(options DescribeClusterOptions) (*tree.ObjectTree, error) { + return f.internalClient.DescribeCluster(options) +} + // newFakeClient returns a clusterctl client that allows to execute tests on a set of fake config, fake repositories and fake clusters. // you can use WithCluster and WithRepository to prepare for the test case. func newFakeClient(configClient config.Client) *fakeClient { diff --git a/cmd/clusterctl/client/describe.go b/cmd/clusterctl/client/describe.go new file mode 100644 index 000000000000..78bea56c6a45 --- /dev/null +++ b/cmd/clusterctl/client/describe.go @@ -0,0 +1,79 @@ +/* +Copyright 2020 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 client + +import ( + "context" + + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/tree" +) + +// DescribeClusterOptions carries the options supported by DescribeCluster. +type DescribeClusterOptions struct { + // Kubeconfig defines the kubeconfig to use for accessing the management cluster. If empty, + // default rules for kubeconfig discovery will be used. + Kubeconfig Kubeconfig + + // Namespace where the workload cluster is located. If unspecified, the current namespace will be used. + Namespace string + + // ClusterName to be used for the workload cluster. + ClusterName string + + // ShowOtherConditions is a list of comma separated kind or kind/name for which we should add the ShowObjectConditionsAnnotation + // to signal to the presentation layer to show all the conditions for the objects. + ShowOtherConditions string + + // DisableNoEcho disable hiding MachineInfrastructure or BootstrapConfig objects if the object's ready condition is true + // or it has the same Status, Severity and Reason of the parent's object ready condition (it is an echo) + DisableNoEcho bool + + // DisableGrouping disable grouping machines objects in case the ready condition + // has the same Status, Severity and Reason + DisableGrouping bool +} + +// DescribeCluster returns the object tree representing the status of a Cluster API cluster. +func (c *clusterctlClient) DescribeCluster(options DescribeClusterOptions) (*tree.ObjectTree, error) { + // gets access to the management cluster + cluster, err := c.clusterClientFactory(ClusterClientFactoryInput{Kubeconfig: options.Kubeconfig}) + if err != nil { + return nil, err + } + + // If the option specifying the Namespace is empty, try to detect it. + if options.Namespace == "" { + currentNamespace, err := cluster.Proxy().CurrentNamespace() + if err != nil { + return nil, err + } + options.Namespace = currentNamespace + } + + // Fetch the Cluster client. + client, err := cluster.Proxy().NewClient() + if err != nil { + return nil, err + } + + // Gets the object tree representing the status of a Cluster API cluster. + return tree.Discovery(context.TODO(), client, options.Namespace, options.ClusterName, tree.DiscoverOptions{ + ShowOtherConditions: options.ShowOtherConditions, + DisableNoEcho: options.DisableNoEcho, + DisableGrouping: options.DisableGrouping, + }) +} diff --git a/cmd/clusterctl/client/tree/annotations.go b/cmd/clusterctl/client/tree/annotations.go new file mode 100644 index 000000000000..50d9bdc97379 --- /dev/null +++ b/cmd/clusterctl/client/tree/annotations.go @@ -0,0 +1,131 @@ +/* +Copyright 2020 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 ( + "strconv" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + // ShowObjectConditionsAnnotation documents that the presentation layer should show all the conditions for the object. + ShowObjectConditionsAnnotation = "tree.cluster.x-k8s.io.io/show-conditions" + + // ObjectMetaNameAnnotation contains the meta name that should be used for the object in the presentation layer, + // e.g. control plane for KCP. + ObjectMetaNameAnnotation = "tree.cluster.x-k8s.io.io/meta-name" + + // VirtualObjectAnnotation documents that the object does not correspond to any real object, but instead is + // a virtual object introduced to provide a better representation of the cluster status, e.g. workers. + VirtualObjectAnnotation = "tree.cluster.x-k8s.io.io/virtual-object" + + // GroupingObjectAnnotation is an annotation that should be applied to a node in order to trigger the grouping action + // when adding the node's children. e.g. if you have a control-plane node, and you apply this annotation, then + // the control-plane machines added as a children of this node will be grouped in case the ready condition + // has the same Status, Severity and Reason. + GroupingObjectAnnotation = "tree.cluster.x-k8s.io.io/grouping-object" + + // GroupObjectAnnotation is an annotation that documents that a node is the result of a grouping operation, and + // thus the node is representing group of sibling nodes, e.g. a group of machines. + GroupObjectAnnotation = "tree.cluster.x-k8s.io.io/group-object" + + // GroupItemsAnnotation contains the list of names for the objects included in a group object. + GroupItemsAnnotation = "tree.cluster.x-k8s.io.io/group-items" + + // GroupItemsSeparator is the separator used in the GroupItemsAnnotation + GroupItemsSeparator = ", " +) + +// GetMetaName returns the object meta name that should be used for the object in the presentation layer, if defined. +func GetMetaName(obj client.Object) string { + if val, ok := getAnnotation(obj, ObjectMetaNameAnnotation); ok { + return val + } + return "" +} + +// IsGroupingObject returns true in case the object is responsible to trigger the grouping action +// when adding the object's children. e.g. A control-plane object, could be responsible of grouping +// the control-plane machines while added as a children objects. +func IsGroupingObject(obj client.Object) bool { + if val, ok := getBoolAnnotation(obj, GroupingObjectAnnotation); ok { + return val + } + return false +} + +// IsGroupObject return true if the object is the result of a grouping operation, and +// thus the object is representing group of sibling object, e.g. a group of machines. +func IsGroupObject(obj client.Object) bool { + if val, ok := getBoolAnnotation(obj, GroupObjectAnnotation); ok { + return val + } + return false +} + +// GetGroupItems return the list of names for the objects included in a group object. +func GetGroupItems(obj client.Object) string { + if val, ok := getAnnotation(obj, GroupItemsAnnotation); ok { + return val + } + return "" +} + +// IsVirtualObject return true if the object does not correspond to any real object, but instead it is +// a virtual object introduced to provide a better representation of the cluster status. +func IsVirtualObject(obj client.Object) bool { + if val, ok := getBoolAnnotation(obj, VirtualObjectAnnotation); ok { + return val + } + return false +} + +// IsShowConditionsObject returns true if the presentation layer should show all the conditions for the object. +func IsShowConditionsObject(obj client.Object) bool { + if val, ok := getBoolAnnotation(obj, ShowObjectConditionsAnnotation); ok { + return val + } + return false +} + +func getAnnotation(obj client.Object, annotation string) (string, bool) { + if obj == nil { + return "", false + } + val, ok := obj.GetAnnotations()[annotation] + return val, ok +} + +func getBoolAnnotation(obj client.Object, annotation string) (bool, bool) { + val, ok := getAnnotation(obj, annotation) + if ok { + if boolVal, err := strconv.ParseBool(val); err == nil { + return boolVal, true + } + } + return false, false +} + +func addAnnotation(obj client.Object, annotation, value string) { + annotations := obj.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + annotations[annotation] = value + obj.SetAnnotations(annotations) +} diff --git a/cmd/clusterctl/client/tree/discovery.go b/cmd/clusterctl/client/tree/discovery.go new file mode 100644 index 000000000000..97b55acd8e6a --- /dev/null +++ b/cmd/clusterctl/client/tree/discovery.go @@ -0,0 +1,230 @@ +/* +Copyright 2020 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 ( + "context" + + clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4" + "sigs.k8s.io/cluster-api/controllers/external" + "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// DiscoverOptions define options for the discovery process. +type DiscoverOptions struct { + // ShowOtherConditions is a list of comma separated kind or kind/name for which we should add the ShowObjectConditionsAnnotation + // to signal to the presentation layer to show all the conditions for the objects. + ShowOtherConditions string + + // DisableNoEcho disable hiding MachineInfrastructure or BootstrapConfig objects if the object's ready condition is true + // or it has the same Status, Severity and Reason of the parent's object ready condition (it is an echo) + DisableNoEcho bool + + // DisableGrouping disable grouping machines objects in case the ready condition + // has the same Status, Severity and Reason + DisableGrouping bool +} + +func (d DiscoverOptions) toObjectTreeOptions() ObjectTreeOptions { + return ObjectTreeOptions{ + ShowOtherConditions: d.ShowOtherConditions, + DisableNoEcho: d.DisableNoEcho, + DisableGrouping: d.DisableGrouping, + } +} + +// Discovery returns an object tree representing the status of a Cluster API cluster. +func Discovery(ctx context.Context, c client.Client, namespace, name string, options DiscoverOptions) (*ObjectTree, error) { + // Fetch the Cluster instance. + cluster := &clusterv1.Cluster{} + clusterKey := client.ObjectKey{ + Namespace: namespace, + Name: name, + } + if err := c.Get(ctx, clusterKey, cluster); err != nil { + return nil, err + } + + // Create an object tree with the cluster as root + tree := NewObjectTree(cluster, options.toObjectTreeOptions()) + + // Adds cluster infra + if clusterInfra, err := external.Get(ctx, c, cluster.Spec.InfrastructureRef, cluster.Namespace); err == nil { + tree.Add(cluster, clusterInfra, ObjectMetaName("ClusterInfrastructure")) + } + + // Adds control plane + controlPLane, err := external.Get(ctx, c, cluster.Spec.ControlPlaneRef, cluster.Namespace) + if err == nil { + tree.Add(cluster, controlPLane, ObjectMetaName("ControlPlane"), GroupingObject(true)) + } + + // Adds control plane machines. + machinesList, err := getMachinesInCluster(ctx, c, cluster.Namespace, cluster.Name) + if err != nil { + return nil, err + } + machineMap := map[string]bool{} + addMachineFunc := func(parent client.Object, m *clusterv1.Machine) { + _, visible := tree.Add(parent, m) + machineMap[m.Name] = true + + if visible { + if machineInfra, err := external.Get(ctx, c, &m.Spec.InfrastructureRef, cluster.Namespace); err == nil { + tree.Add(m, machineInfra, ObjectMetaName("MachineInfrastructure"), NoEcho(true)) + } + + if machineBootstrap, err := external.Get(ctx, c, m.Spec.Bootstrap.ConfigRef, cluster.Namespace); err == nil { + tree.Add(m, machineBootstrap, ObjectMetaName("BootstrapConfig"), NoEcho(true)) + } + } + } + + controlPlaneMachines := selectControlPlaneMachines(machinesList) + for i := range controlPlaneMachines { + cp := controlPlaneMachines[i] + addMachineFunc(controlPLane, cp) + } + + if len(machinesList.Items) == len(controlPlaneMachines) { + return tree, nil + } + + workers := VirtualObject(cluster.Namespace, "WorkerGroup", "Workers") + tree.Add(cluster, workers) + + // Adds worker machines. + machinesDeploymentList, err := getMachineDeploymentsInCluster(ctx, c, cluster.Namespace, cluster.Name) + if err != nil { + return nil, err + } + + machineSetList, err := getMachineSetsInCluster(ctx, c, cluster.Namespace, cluster.Name) + if err != nil { + return nil, err + } + + for i := range machinesDeploymentList.Items { + md := &machinesDeploymentList.Items[i] + tree.Add(workers, md, GroupingObject(true)) + + machineSets := selectMachinesSetsControlledBy(machineSetList, md) + for i := range machineSets { + ms := machineSets[i] + + machines := selectMachinesControlledBy(machinesList, ms) + for _, w := range machines { + addMachineFunc(md, w) + } + } + } + + // Handles orphan machines. + if len(machineMap) < len(machinesList.Items) { + other := VirtualObject(cluster.Namespace, "OtherGroup", "Other") + tree.Add(workers, other) + + for i := range machinesList.Items { + m := &machinesList.Items[i] + if _, ok := machineMap[m.Name]; ok { + continue + } + addMachineFunc(other, m) + } + } + + return tree, nil +} + +func getMachinesInCluster(ctx context.Context, c client.Client, namespace, name string) (*clusterv1.MachineList, error) { + if name == "" { + return nil, nil + } + + machineList := &clusterv1.MachineList{} + labels := map[string]string{clusterv1.ClusterLabelName: name} + + if err := c.List(ctx, machineList, client.InNamespace(namespace), client.MatchingLabels(labels)); err != nil { + return nil, err + } + + return machineList, nil +} + +func getMachineDeploymentsInCluster(ctx context.Context, c client.Client, namespace, name string) (*clusterv1.MachineDeploymentList, error) { + if name == "" { + return nil, nil + } + + machineDeploymentList := &clusterv1.MachineDeploymentList{} + labels := map[string]string{clusterv1.ClusterLabelName: name} + + if err := c.List(ctx, machineDeploymentList, client.InNamespace(namespace), client.MatchingLabels(labels)); err != nil { + return nil, err + } + + return machineDeploymentList, nil +} + +func getMachineSetsInCluster(ctx context.Context, c client.Client, namespace, name string) (*clusterv1.MachineSetList, error) { + if name == "" { + return nil, nil + } + + machineSetList := &clusterv1.MachineSetList{} + labels := map[string]string{clusterv1.ClusterLabelName: name} + + if err := c.List(ctx, machineSetList, client.InNamespace(namespace), client.MatchingLabels(labels)); err != nil { + return nil, err + } + + return machineSetList, nil +} + +func selectControlPlaneMachines(machineList *clusterv1.MachineList) []*clusterv1.Machine { + machines := []*clusterv1.Machine{} + for i := range machineList.Items { + m := &machineList.Items[i] + if util.IsControlPlaneMachine(m) { + machines = append(machines, m) + } + } + return machines +} + +func selectMachinesSetsControlledBy(machineSetList *clusterv1.MachineSetList, controller client.Object) []*clusterv1.MachineSet { + machineSets := []*clusterv1.MachineSet{} + for i := range machineSetList.Items { + m := &machineSetList.Items[i] + if util.IsControlledBy(m, controller) { + machineSets = append(machineSets, m) + } + } + return machineSets +} + +func selectMachinesControlledBy(machineList *clusterv1.MachineList, controller client.Object) []*clusterv1.Machine { + machines := []*clusterv1.Machine{} + for i := range machineList.Items { + m := &machineList.Items[i] + if util.IsControlledBy(m, controller) { + machines = append(machines, m) + } + } + return machines +} diff --git a/cmd/clusterctl/client/tree/discovery_test.go b/cmd/clusterctl/client/tree/discovery_test.go new file mode 100644 index 000000000000..4171a0e9908e --- /dev/null +++ b/cmd/clusterctl/client/tree/discovery_test.go @@ -0,0 +1,305 @@ +/* +Copyright 2020 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 ( + "context" + "strings" + "testing" + + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/cluster-api/cmd/clusterctl/internal/test" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func Test_Discovery(t *testing.T) { + type nodeCheck func(*WithT, client.Object) + type args struct { + objs []client.Object + discoverOptions DiscoverOptions + } + tests := []struct { + name string + args args + wantTree map[string][]string + wantNodeCheck map[string]nodeCheck + }{ + { + name: "Discovery with default discovery settings", + args: args{ + discoverOptions: DiscoverOptions{}, + objs: test.NewFakeCluster("ns1", "cluster1"). + WithControlPlane( + test.NewFakeControlPlane("cp"). + WithMachines( + test.NewFakeMachine("cp1"), + ), + ). + WithMachineDeployments( + test.NewFakeMachineDeployment("md1"). + WithMachineSets( + test.NewFakeMachineSet("ms1"). + WithMachines( + test.NewFakeMachine("m1"), + test.NewFakeMachine("m2"), + ), + ), + ). + Objs(), + }, + wantTree: map[string][]string{ + // Cluster should be parent of InfrastructureCluster, ControlPlane, and WorkerNodes + "cluster.x-k8s.io/v1alpha4, Kind=Cluster, ns1/cluster1": { + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1", + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp", + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers", + }, + // InfrastructureCluster should be leaf + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1": {}, + // ControlPlane should have a machine + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp": { + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/cp1", + }, + // Machine should be leaf (no echo) + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/cp1": {}, + // Workers should have a machine deployment + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers": { + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1", + }, + // Machine deployment should have a group of machines (grouping) + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1": { + "virtual.cluster.x-k8s.io/v1alpha4, ns1/zzz_", + }, + }, + wantNodeCheck: map[string]nodeCheck{ + // InfrastructureCluster should have a meta name + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("ClusterInfrastructure")) + }, + // ControlPlane should have a meta name, be a grouping object + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("ControlPlane")) + g.Expect(IsGroupingObject(obj)).To(BeTrue()) + }, + // Workers should be a virtual node + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers": func(g *WithT, obj client.Object) { + g.Expect(IsVirtualObject(obj)).To(BeTrue()) + }, + // Machine deployment should be a grouping object + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1": func(g *WithT, obj client.Object) { + g.Expect(IsGroupingObject(obj)).To(BeTrue()) + }, + }, + }, + { + name: "Discovery with grouping disabled", + args: args{ + discoverOptions: DiscoverOptions{ + DisableGrouping: true, + }, + objs: test.NewFakeCluster("ns1", "cluster1"). + WithControlPlane( + test.NewFakeControlPlane("cp"). + WithMachines( + test.NewFakeMachine("cp1"), + ), + ). + WithMachineDeployments( + test.NewFakeMachineDeployment("md1"). + WithMachineSets( + test.NewFakeMachineSet("ms1"). + WithMachines( + test.NewFakeMachine("m1"), + test.NewFakeMachine("m2"), + ), + ), + ). + Objs(), + }, + wantTree: map[string][]string{ + // Cluster should be parent of InfrastructureCluster, ControlPlane, and WorkerNodes + "cluster.x-k8s.io/v1alpha4, Kind=Cluster, ns1/cluster1": { + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1", + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp", + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers", + }, + // InfrastructureCluster should be leaf + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1": {}, + // ControlPlane should have a machine + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp": { + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/cp1", + }, + // Workers should have a machine deployment + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers": { + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1", + }, + // Machine deployment should have a group of machines + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1": { + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/m1", + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/m2", + }, + // Machine should be leaf (no echo) + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/cp1": {}, + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/m1": {}, + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/m2": {}, + }, + wantNodeCheck: map[string]nodeCheck{ + // InfrastructureCluster should have a meta name + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("ClusterInfrastructure")) + }, + // ControlPlane should have a meta name, should NOT be a grouping object + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("ControlPlane")) + g.Expect(IsGroupingObject(obj)).To(BeFalse()) + }, + // Workers should be a virtual node + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers": func(g *WithT, obj client.Object) { + g.Expect(IsVirtualObject(obj)).To(BeTrue()) + }, + // Machine deployment should NOT be a grouping object + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1": func(g *WithT, obj client.Object) { + g.Expect(IsGroupingObject(obj)).To(BeFalse()) + }, + }, + }, + { + name: "Discovery with grouping and no-echo disabled", + args: args{ + discoverOptions: DiscoverOptions{ + DisableGrouping: true, + DisableNoEcho: true, + }, + objs: test.NewFakeCluster("ns1", "cluster1"). + WithControlPlane( + test.NewFakeControlPlane("cp"). + WithMachines( + test.NewFakeMachine("cp1"), + ), + ). + WithMachineDeployments( + test.NewFakeMachineDeployment("md1"). + WithMachineSets( + test.NewFakeMachineSet("ms1"). + WithMachines( + test.NewFakeMachine("m1"), + ), + ), + ). + Objs(), + }, + wantTree: map[string][]string{ + // Cluster should be parent of InfrastructureCluster, ControlPlane, and WorkerNodes + "cluster.x-k8s.io/v1alpha4, Kind=Cluster, ns1/cluster1": { + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1", + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp", + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers", + }, + // InfrastructureCluster should be leaf + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1": {}, + // ControlPlane should have a machine + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp": { + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/cp1", + }, + // Machine should have infra machine and bootstrap (echo) + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/cp1": { + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureMachine, ns1/cp1", + "bootstrap.cluster.x-k8s.io/v1alpha4, Kind=GenericBootstrapConfig, ns1/cp1", + }, + // Workers should have a machine deployment + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers": { + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1", + }, + // Machine deployment should have a group of machines + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1": { + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/m1", + }, + // Machine should have infra machine and bootstrap (echo) + "cluster.x-k8s.io/v1alpha4, Kind=Machine, ns1/m1": { + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureMachine, ns1/m1", + "bootstrap.cluster.x-k8s.io/v1alpha4, Kind=GenericBootstrapConfig, ns1/m1", + }, + }, + wantNodeCheck: map[string]nodeCheck{ + // InfrastructureCluster should have a meta name + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureCluster, ns1/cluster1": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("ClusterInfrastructure")) + }, + // ControlPlane should have a meta name, should NOT be a grouping object + "controlplane.cluster.x-k8s.io/v1alpha4, Kind=GenericControlPlane, ns1/cp": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("ControlPlane")) + g.Expect(IsGroupingObject(obj)).To(BeFalse()) + }, + // Workers should be a virtual node + "virtual.cluster.x-k8s.io/v1alpha4, ns1/Workers": func(g *WithT, obj client.Object) { + g.Expect(IsVirtualObject(obj)).To(BeTrue()) + }, + // Machine deployment should NOT be a grouping object + "cluster.x-k8s.io/v1alpha4, Kind=MachineDeployment, ns1/md1": func(g *WithT, obj client.Object) { + g.Expect(IsGroupingObject(obj)).To(BeFalse()) + }, + // infra machines and boostrap should have meta names + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureMachine, ns1/cp1": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("MachineInfrastructure")) + }, + "bootstrap.cluster.x-k8s.io/v1alpha4, Kind=GenericBootstrapConfig, ns1/cp1": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("BootstrapConfig")) + }, + "infrastructure.cluster.x-k8s.io/v1alpha4, Kind=GenericInfrastructureMachine, ns1/m1": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("MachineInfrastructure")) + }, + "bootstrap.cluster.x-k8s.io/v1alpha4, Kind=GenericBootstrapConfig, ns1/m1": func(g *WithT, obj client.Object) { + g.Expect(GetMetaName(obj)).To(Equal("BootstrapConfig")) + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + client, err := test.NewFakeProxy().WithObjs(tt.args.objs...).NewClient() + g.Expect(client).ToNot(BeNil()) + g.Expect(err).ToNot(HaveOccurred()) + + tree, err := Discovery(context.TODO(), client, "ns1", "cluster1", tt.args.discoverOptions) + g.Expect(tree).ToNot(BeNil()) + g.Expect(err).ToNot(HaveOccurred()) + + for parent, wantChildren := range tt.wantTree { + gotChildren := tree.GetObjectsByParent(types.UID(parent)) + g.Expect(wantChildren).To(HaveLen(len(gotChildren)), "%q doesn't have the expected number of children nodes", parent) + + for _, gotChild := range gotChildren { + found := false + for _, wantChild := range wantChildren { + if strings.HasPrefix(string(gotChild.GetUID()), wantChild) { + found = true + break + } + } + g.Expect(found).To(BeTrue(), "got child %q for parent %q, expecting [%s]", gotChild.GetUID(), parent, strings.Join(wantChildren, "] [")) + + if test, ok := tt.wantNodeCheck[string(gotChild.GetUID())]; ok { + test(g, gotChild) + } + } + } + }) + } +} diff --git a/cmd/clusterctl/client/tree/doc.go b/cmd/clusterctl/client/tree/doc.go new file mode 100644 index 000000000000..9ba3ff635089 --- /dev/null +++ b/cmd/clusterctl/client/tree/doc.go @@ -0,0 +1,55 @@ +/* +Copyright 2020 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 + +/* +This package support the generation of an "at glance" view of a Cluster API cluster designed to help the user in quickly +understanding if there are problems and where. + +The "at glance" view is based on the idea that we should avoid to overload the user with information, but instead +surface problems, if any; in practice: + +- The view assumes we are processing objects conforming with https://github.com/kubernetes-sigs/cluster-api/blob/master/docs/proposals/20200506-conditions.md. + As a consequence each object should have a Ready condition summarizing the object state. + +- The view organizes objects in a hierarchical tree, however it is not required that the + tree reflects the ownerReference tree so it is possible to skip objects not relevant for triaging the cluster status + e.g. secrets or templates. + +- It is possible to add "meta names" to object, thus making hierarchical tree more consistent for the users, + e.g. use MachineInfrastructure instead of using all the different infrastructure machine kinds (AWSMachine, VSphereMachine etc.). + +- It is possible to add "virtual nodes", thus allowing to make the hierarchical tree more meaningful for the users, + e.g. adding a Workers object to group all the MachineDeployments. + +- It is possible to "group" siblings objects by ready condition e.g. group all the machines with Ready=true + in a single node instead of listing each one of them. + +- Given that the ready condition of the child object bubbles up to the parents, it is possible to avoid the "echo" + (reporting the same condition at the parent/child) e.g. if a machine's Ready condition is already + surface an error from the infrastructure machine, let's avoid to show the InfrastructureMachine + given that representing its state is redundant in this case. + +- In order to avoid long list of objects (think e.g. a cluster with 50 worker machines), sibling objects with the + same value for the ready condition can be grouped together into a virtual node, e.g. 10 Machines ready + +The ObjectTree object defined implements all the above behaviors of the "at glance" visualization, by generating +a tree of Kubernetes objects; each object gets a set of annotation, reflecting its own visualization specific attributes, +e.g is virtual node, is group node, meta name etc. + +The Discovery object uses the ObjectTree to build the "at glance" view of a Cluster API. +*/ diff --git a/cmd/clusterctl/client/tree/options.go b/cmd/clusterctl/client/tree/options.go new file mode 100644 index 000000000000..607c4ca61c99 --- /dev/null +++ b/cmd/clusterctl/client/tree/options.go @@ -0,0 +1,59 @@ +/* +Copyright 2020 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 + +// AddObjectOption define an option for the ObjectTree Add operation. +type AddObjectOption interface { + ApplyToAdd(*addObjectOptions) +} + +type addObjectOptions struct { + MetaName string + GroupingObject bool + NoEcho bool +} + +func (o *addObjectOptions) ApplyOptions(opts []AddObjectOption) *addObjectOptions { + for _, opt := range opts { + opt.ApplyToAdd(o) + } + return o +} + +// The ObjectMetaName option defines the meta name that should be used for the object in the presentation layer, +// e.g. control plane for KCP. +type ObjectMetaName string + +func (n ObjectMetaName) ApplyToAdd(options *addObjectOptions) { + options.MetaName = string(n) +} + +// The GroupingObject option makes this node responsible of triggering the grouping action +// when adding the node's children. +type GroupingObject bool + +func (n GroupingObject) ApplyToAdd(options *addObjectOptions) { + options.GroupingObject = bool(n) +} + +// The NoEcho options defines if the object should be hidden if the object's ready condition has the +// same Status, Severity and Reason of the parent's object ready condition (it is an echo). +type NoEcho bool + +func (n NoEcho) ApplyToAdd(options *addObjectOptions) { + options.NoEcho = bool(n) +} diff --git a/cmd/clusterctl/client/tree/tree.go b/cmd/clusterctl/client/tree/tree.go new file mode 100644 index 000000000000..2dd70905676e --- /dev/null +++ b/cmd/clusterctl/client/tree/tree.go @@ -0,0 +1,273 @@ +/* +Copyright 2020 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 ( + "fmt" + "sort" + "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4" + "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type ObjectTreeOptions struct { + // ShowOtherConditions is a list of comma separated kind or kind/name for which we should add the ShowObjectConditionsAnnotation + // to signal to the presentation layer to show all the conditions for the objects. + ShowOtherConditions string + + // DisableNoEcho disables hiding objects if the object's ready condition has the + // same Status, Severity and Reason of the parent's object ready condition (it is an echo) + DisableNoEcho bool + + // DisableGrouping disables grouping sibling objects in case the ready condition + // has the same Status, Severity and Reason + DisableGrouping bool +} + +// ObjectTree defines an object tree representing the status of a Cluster API cluster. +type ObjectTree struct { + root client.Object + options ObjectTreeOptions + items map[types.UID]client.Object + ownership map[types.UID]map[types.UID]bool +} + +func NewObjectTree(root client.Object, options ObjectTreeOptions) *ObjectTree { + return &ObjectTree{ + root: root, + options: options, + items: make(map[types.UID]client.Object), + ownership: make(map[types.UID]map[types.UID]bool), + } +} + +// Add a object to the object tree. +func (od ObjectTree) Add(parent, obj client.Object, opts ...AddObjectOption) (added bool, visible bool) { + if parent == nil || obj == nil { + return false, false + } + addOpts := &addObjectOptions{} + addOpts.ApplyOptions(opts) + + 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. + if isObjDebug(obj, od.options.ShowOtherConditions) { + 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 addOpts.NoEcho && !od.options.DisableNoEcho { + if (objReady != nil && objReady.Status == corev1.ConditionTrue) || hasSameReadyStatusSeverityAndReason(parentReady, objReady) { + return false, false + } + } + + // If it is requested to use a meta name for the object in the presentation layer, add + // the ObjectMetaNameAnnotation to signal this to the presentation layer. + if addOpts.MetaName != "" { + addAnnotation(obj, ObjectMetaNameAnnotation, addOpts.MetaName) + } + + // If it is requested that this object and its sibling should be grouped in case the ready condition + // has the same Status, Severity and Reason, process all the sibling nodes. + if IsGroupingObject(parent) { + siblings := od.GetObjectsByParent(parent.GetUID()) + + 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 + } + + // If the sibling node is already a group object, upgrade it with the current object. + if IsGroupObject(s) { + updateGroupNode(s, sReady, obj, objReady) + return true, false + } + + // 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) + od.addInner(parent, groupNode) + + // Remove the current sibling (now merged in the group). + od.remove(parent, s) + return true, false + } + } + + // If it is requested that the child of this node should be grouped in case the ready condition + // has the same Status, Severity and Reason, add the GroupingObjectAnnotation to signal + // this to the presentation layer. + if addOpts.GroupingObject && !od.options.DisableGrouping { + addAnnotation(obj, GroupingObjectAnnotation, "True") + } + + // Add the object to the object tree. + od.addInner(parent, obj) + + return true, true +} + +func (od ObjectTree) remove(parent client.Object, s client.Object) { + for _, child := range od.GetObjectsByParent(s.GetUID()) { + od.remove(s, child) + } + delete(od.items, s.GetUID()) + delete(od.ownership[parent.GetUID()], s.GetUID()) +} + +func (od ObjectTree) addInner(parent client.Object, obj client.Object) { + od.items[obj.GetUID()] = obj + if od.ownership[parent.GetUID()] == nil { + od.ownership[parent.GetUID()] = make(map[types.UID]bool) + } + od.ownership[parent.GetUID()][obj.GetUID()] = true +} + +func (od ObjectTree) GetRoot() client.Object { return od.root } + +func (od ObjectTree) GetObject(id types.UID) client.Object { return od.items[id] } + +func (od ObjectTree) IsObjectWithChild(id types.UID) bool { + return len(od.ownership[id]) > 0 +} + +func (od ObjectTree) GetObjectsByParent(id types.UID) []client.Object { + out := make([]client.Object, 0, len(od.ownership[id])) + for k := range od.ownership[id] { + out = append(out, od.GetObject(k)) + } + return out +} + +func hasSameReadyStatusSeverityAndReason(a, b *clusterv1.Condition) bool { + if a == nil && b == nil { + return true + } + if (a == nil) != (b == nil) { + return false + } + + return a.Status == b.Status && + a.Severity == b.Severity && + a.Reason == b.Reason +} + +func createGroupNode(sibling client.Object, siblingReady *clusterv1.Condition, obj client.Object, objReady *clusterv1.Condition) *unstructured.Unstructured { + 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, readyStatusSeverityAndReasonUID(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 ready condition. + if objReady != nil { + objReady.LastTransitionTime = minLastTransitionTime(objReady, siblingReady) + objReady.Message = "" + setReadyCondition(groupNode, objReady) + } + return groupNode +} + +func readyStatusSeverityAndReasonUID(obj client.Object) string { + ready := GetReadyCondition(obj) + if ready == nil { + return fmt.Sprintf("zzz_%s", util.RandomString(6)) + } + return fmt.Sprintf("zz_%s_%s_%s_%s", ready.Status, ready.Severity, ready.Reason, util.RandomString(6)) +} + +func minLastTransitionTime(a, b *clusterv1.Condition) metav1.Time { + if a == nil && b == nil { + return metav1.Time{} + } + if (a != nil) && (b == nil) { + return a.LastTransitionTime + } + if (a == nil) && (b != nil) { + return b.LastTransitionTime + } + if a.LastTransitionTime.Time.After(b.LastTransitionTime.Time) { + return b.LastTransitionTime + } + return a.LastTransitionTime +} + +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) + items = append(items, obj.GetName()) + sort.Strings(items) + addAnnotation(groupObj, GroupItemsAnnotation, strings.Join(items, GroupItemsSeparator)) + + // Update the group's ready condition. + if groupReady != nil { + groupReady.LastTransitionTime = minLastTransitionTime(objReady, groupReady) + groupReady.Message = "" + setReadyCondition(groupObj, groupReady) + } +} + +func isObjDebug(obj client.Object, debugFilter string) bool { + if debugFilter == "" { + return false + } + for _, filter := range strings.Split(debugFilter, ",") { + filter = strings.TrimSpace(filter) + if filter == "" { + continue + } + if strings.ToLower(filter) == "all" { + return true + } + kn := strings.Split(filter, "/") + if len(kn) == 2 { + if obj.GetObjectKind().GroupVersionKind().Kind == kn[0] && obj.GetName() == kn[1] { + return true + } + continue + } + if obj.GetObjectKind().GroupVersionKind().Kind == kn[0] { + return true + } + } + return false +} diff --git a/cmd/clusterctl/client/tree/tree_test.go b/cmd/clusterctl/client/tree/tree_test.go new file mode 100644 index 000000000000..c90e8cc9f10e --- /dev/null +++ b/cmd/clusterctl/client/tree/tree_test.go @@ -0,0 +1,765 @@ +/* +Copyright 2020 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 ( + "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" + clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4" + "sigs.k8s.io/cluster-api/util/conditions" +) + +func Test_hasSameReadyStatusSeverityAndReason(t *testing.T) { + readyTrue := conditions.TrueCondition(clusterv1.ReadyCondition) + readyFalseReasonInfo := conditions.FalseCondition(clusterv1.ReadyCondition, "Reason", clusterv1.ConditionSeverityInfo, "message falseInfo1") + readyFalseAnotherReasonInfo := conditions.FalseCondition(clusterv1.ReadyCondition, "AnotherReason", clusterv1.ConditionSeverityInfo, "message falseInfo1") + readyFalseReasonWarning := conditions.FalseCondition(clusterv1.ReadyCondition, "Reason", clusterv1.ConditionSeverityWarning, "message falseInfo1") + + type args struct { + a *clusterv1.Condition + b *clusterv1.Condition + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "Objects without conditions are the same", + args: args{ + a: nil, + b: nil, + }, + want: true, + }, + { + name: "Objects with same Ready condition are the same", + args: args{ + a: readyTrue, + b: readyTrue, + }, + want: true, + }, + { + name: "Objects with different Ready.Status are not the same", + args: args{ + a: readyTrue, + b: readyFalseReasonInfo, + }, + want: false, + }, + { + name: "Objects with different Ready.Reason are not the same", + args: args{ + a: readyFalseReasonInfo, + b: readyFalseAnotherReasonInfo, + }, + want: false, + }, + { + name: "Objects with different Ready.Severity are not the same", + args: args{ + a: readyFalseReasonInfo, + b: readyFalseReasonWarning, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := hasSameReadyStatusSeverityAndReason(tt.args.a, tt.args.b) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +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)}} + type args struct { + a *clusterv1.Condition + b *clusterv1.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 := minLastTransitionTime(tt.args.a, tt.args.b) + g.Expect(got.Time).To(BeTemporally("~", tt.want.Time)) + }) + } +} + +func Test_isObjDebug(t *testing.T) { + obj := fakeMachine("my-machine") + type args struct { + filter string + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "empty filter should return false", + args: args{ + filter: "", + }, + want: false, + }, + { + name: "all filter should return true", + args: args{ + filter: "all", + }, + want: true, + }, + { + name: "kind filter should return true", + args: args{ + filter: "Machine", + }, + want: true, + }, + { + name: "another kind filter should return false", + args: args{ + filter: "AnotherKind", + }, + want: false, + }, + { + name: "kind/name filter should return true", + args: args{ + filter: "Machine/my-machine", + }, + want: true, + }, + { + name: "kind/wrong name filter should return false", + args: args{ + filter: "Cluster/another-cluster", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + got := isObjDebug(obj, tt.args.filter) + g.Expect(got).To(Equal(tt.want)) + }) + } +} + +func Test_createGroupNode(t *testing.T) { + now := metav1.Now() + beforeNow := metav1.Time{Time: now.Time.Add(-1 * time.Hour)} + + obj := &clusterv1.Machine{ + TypeMeta: metav1.TypeMeta{ + Kind: "Machine", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "my-machine", + }, + Status: clusterv1.MachineStatus{ + Conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ReadyCondition, LastTransitionTime: now}, + }, + }, + } + + sibling := &clusterv1.Machine{ + TypeMeta: metav1.TypeMeta{ + Kind: "Machine", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: "sibling-machine", + }, + Status: clusterv1.MachineStatus{ + Conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ReadyCondition, LastTransitionTime: beforeNow}, + }, + }, + } + + want := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "virtual.cluster.x-k8s.io/v1alpha4", + "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 + }, + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "status": "", + "lastTransitionTime": beforeNow.Time.UTC().Format(time.RFC3339), + "type": "Ready", + }, + }, + }, + }, + } + + g := NewWithT(t) + got := createGroupNode(sibling, GetReadyCondition(sibling), obj, GetReadyCondition(obj)) + + // Some values are generated randomly, so pick up them. + want.SetName(got.GetName()) + want.SetUID(got.GetUID()) + + g.Expect(got).To(Equal(want)) +} + +func Test_updateGroupNode(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/v1alpha4", + "kind": "MachineGroup", + "metadata": map[string]interface{}{ + "namespace": "ns", + "name": "random-name", + "annotations": map[string]interface{}{ + VirtualObjectAnnotation: "True", + GroupObjectAnnotation: "True", + GroupItemsAnnotation: "my-machine, sibling-machine", + }, + "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{ + Conditions: clusterv1.Conditions{ + clusterv1.Condition{Type: clusterv1.ReadyCondition, LastTransitionTime: now}, + }, + }, + } + + want := &unstructured.Unstructured{ + Object: map[string]interface{}{ + "apiVersion": "virtual.cluster.x-k8s.io/v1alpha4", + "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", + }, + "status": map[string]interface{}{ + "conditions": []interface{}{ + map[string]interface{}{ + "status": "", + "lastTransitionTime": beforeNow.Time.UTC().Format(time.RFC3339), + "type": "Ready", + }, + }, + }, + }, + } + + g := NewWithT(t) + updateGroupNode(group, GetReadyCondition(group), obj, GetReadyCondition(obj)) + + g.Expect(group).To(Equal(want)) +} + +func Test_Add_setsShowObjectConditionsAnnotation(t *testing.T) { + parent := fakeCluster("parent") + obj := fakeMachine("my-machine") + + type args struct { + treeOptions ObjectTreeOptions + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "filter selecting my machine should not add the annotation", + args: args{ + treeOptions: ObjectTreeOptions{ShowOtherConditions: "all"}, + }, + want: true, + }, + { + name: "filter not selecting my machine should not add the annotation", + args: args{ + treeOptions: ObjectTreeOptions{ShowOtherConditions: ""}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, tt.args.treeOptions) + + 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)) + } + }) + } +} + +func Test_Add_setsGroupingObjectAnnotation(t *testing.T) { + parent := fakeCluster("parent") + obj := fakeMachine("my-machine") + + type args struct { + treeOptions ObjectTreeOptions + addOptions []AddObjectOption + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "should not add the annotation if not requested to", + args: args{ + treeOptions: ObjectTreeOptions{}, + addOptions: nil, // without GroupingObject option + }, + want: false, + }, + { + name: "should add the annotation if requested to", + args: args{ + treeOptions: ObjectTreeOptions{}, + addOptions: []AddObjectOption{GroupingObject(true)}, + }, + want: true, + }, + { + name: "should not add the annotation if requested to, but grouping is disabled", + args: args{ + treeOptions: ObjectTreeOptions{DisableGrouping: true}, + addOptions: []AddObjectOption{GroupingObject(true)}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := parent.DeepCopy() + 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()) + + 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)) + } + }) + } +} + +func Test_Add_setsObjectMetaNameAnnotation(t *testing.T) { + parent := fakeCluster("parent") + obj := fakeMachine("my-machine") + + type args struct { + addOptions []AddObjectOption + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "should not add the annotation if not requested to", + args: args{ + addOptions: nil, // without ObjectMetaName option + }, + want: false, + }, + { + name: "should add the annotation if requested to", + args: args{ + addOptions: []AddObjectOption{ObjectMetaName("MetaName")}, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, ObjectTreeOptions{}) + + 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(t *testing.T) { + parent := fakeCluster("parent", + withClusterCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ) + + 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", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + }, + 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", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + }, + 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", + withMachineCondition(conditions.FalseCondition(clusterv1.ReadyCondition, "", clusterv1.ConditionSeverityInfo, "")), + ), + }, + wantNode: true, + }, + { + name: "should add if NoEcho option is present, objects have same ReadyCondition, but NoEcho is disabled", + args: args{ + treeOptions: ObjectTreeOptions{DisableNoEcho: true}, + addOptions: []AddObjectOption{NoEcho(true)}, + obj: fakeMachine("my-machine", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + }, + wantNode: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, tt.args.treeOptions) + + g := NewWithT(t) + 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") + switch tt.wantNode { + case true: + g.Expect(gotObj).ToNot(BeNil()) + case false: + g.Expect(gotObj).To(BeNil()) + } + }) + } +} + +func Test_Add_Grouping(t *testing.T) { + parent := fakeCluster("parent", + withClusterAnnotation(GroupingObjectAnnotation, "True"), + ) + + type args struct { + addOptions []AddObjectOption + siblings []*clusterv1.Machine + obj *clusterv1.Machine + } + 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 conditions of an existing one", + args: args{ + siblings: []*clusterv1.Machine{ + fakeMachine("first-machine", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + }, + obj: fakeMachine("second-machine", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + }, + wantNodesPrefix: []string{"zz_True"}, + wantVisible: false, + wantItems: "first-machine, second-machine", + }, + { + name: "should group child node if it has same conditions of an existing group", + args: args{ + siblings: []*clusterv1.Machine{ + fakeMachine("first-machine", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + fakeMachine("second-machine", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + }, + obj: fakeMachine("third-machine", + withMachineCondition(conditions.TrueCondition(clusterv1.ReadyCondition)), + ), + }, + wantNodesPrefix: []string{"zz_True"}, + wantVisible: false, + wantItems: "first-machine, second-machine, third-machine", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := parent.DeepCopy() + tree := NewObjectTree(root, ObjectTreeOptions{}) + + 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)) + } + } + }) + } +} + +type clusterOption func(*clusterv1.Cluster) + +func fakeCluster(name string, options ...clusterOption) *clusterv1.Cluster { // nolint:unparam + c := &clusterv1.Cluster{ + TypeMeta: metav1.TypeMeta{ + Kind: "Cluster", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: name, + UID: types.UID(name), + }, + } + for _, opt := range options { + opt(c) + } + return c +} + +func withClusterAnnotation(name, value string) func(*clusterv1.Cluster) { + return func(c *clusterv1.Cluster) { + if c.Annotations == nil { + c.Annotations = map[string]string{} + } + c.Annotations[name] = value + } +} + +func withClusterCondition(c *clusterv1.Condition) func(*clusterv1.Cluster) { + return func(m *clusterv1.Cluster) { + conditions.Set(m, c) + } +} + +type machineOption func(*clusterv1.Machine) + +func fakeMachine(name string, options ...machineOption) *clusterv1.Machine { + m := &clusterv1.Machine{ + TypeMeta: metav1.TypeMeta{ + Kind: "Machine", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: name, + UID: types.UID(name), + }, + } + for _, opt := range options { + opt(m) + } + return m +} + +func withMachineCondition(c *clusterv1.Condition) func(*clusterv1.Machine) { + return func(m *clusterv1.Machine) { + conditions.Set(m, c) + } +} diff --git a/cmd/clusterctl/client/tree/util.go b/cmd/clusterctl/client/tree/util.go new file mode 100644 index 000000000000..05d24b40bd5d --- /dev/null +++ b/cmd/clusterctl/client/tree/util.go @@ -0,0 +1,108 @@ +/* +Copyright 2020 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 ( + "fmt" + "sort" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4" + "sigs.k8s.io/cluster-api/util/conditions" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// GetReadyCondition returns the ReadyCondition for an object, if defined. +func GetReadyCondition(obj client.Object) *clusterv1.Condition { + getter := objToGetter(obj) + if getter == nil { + return nil + } + return conditions.Get(getter, clusterv1.ReadyCondition) +} + +// 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) + if getter == nil { + return nil + } + var conditions []*clusterv1.Condition + for _, c := range getter.GetConditions() { + c := c + if c.Type != clusterv1.ReadyCondition { + conditions = append(conditions, &c) + } + } + sort.Slice(conditions, func(i, j int) bool { + return conditions[i].Type < conditions[j].Type + }) + return conditions +} + +func setReadyCondition(obj client.Object, ready *clusterv1.Condition) { + setter := objToSetter(obj) + if setter == nil { + return + } + conditions.Set(setter, ready) +} + +func objToGetter(obj client.Object) conditions.Getter { + if getter, ok := obj.(conditions.Getter); ok { + return getter + } + + objUnstructured, ok := obj.(*unstructured.Unstructured) + if !ok { + return nil + } + getter := conditions.UnstructuredGetter(objUnstructured) + return getter +} + +func objToSetter(obj client.Object) conditions.Setter { + if setter, ok := obj.(conditions.Setter); ok { + return setter + } + + objUnstructured, ok := obj.(*unstructured.Unstructured) + if !ok { + return nil + } + setter := conditions.UnstructuredSetter(objUnstructured) + return setter +} + +// VirtualObject return a new virtual object. +func VirtualObject(namespace, kind, name string) *unstructured.Unstructured { + gk := "virtual.cluster.x-k8s.io/v1alpha4" + 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, %s/%s", gk, namespace, name), + }, + }, + } +} diff --git a/cmd/clusterctl/cmd/describe.go b/cmd/clusterctl/cmd/describe.go new file mode 100644 index 000000000000..65e1760f469e --- /dev/null +++ b/cmd/clusterctl/cmd/describe.go @@ -0,0 +1,31 @@ +/* +Copyright 2020 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 cmd + +import ( + "github.com/spf13/cobra" +) + +var describeCmd = &cobra.Command{ + Use: "describe", + Short: "Describe workload clusters.", + Long: `Describe the status of workload clusters.`, +} + +func init() { + RootCmd.AddCommand(describeCmd) +} diff --git a/cmd/clusterctl/cmd/describe_cluster.go b/cmd/clusterctl/cmd/describe_cluster.go new file mode 100644 index 000000000000..da2879e96458 --- /dev/null +++ b/cmd/clusterctl/cmd/describe_cluster.go @@ -0,0 +1,325 @@ +/* +Copyright 2019 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 cmd + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/fatih/color" + "github.com/gobuffalo/flect" + "github.com/gosuri/uitable" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/duration" + clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/tree" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + firstElemPrefix = `├─` + lastElemPrefix = `└─` + indent = " " + pipe = `│ ` +) + +var ( + gray = color.New(color.FgHiBlack) + red = color.New(color.FgRed) + green = color.New(color.FgGreen) + yellow = color.New(color.FgYellow) + white = color.New(color.FgWhite) + cyan = color.New(color.FgCyan) +) + +type describeClusterOptions struct { + kubeconfig string + kubeconfigContext string + + namespace string + showOtherConditions string + disableNoEcho bool + disableGrouping bool +} + +var dc = &describeClusterOptions{} + +var describeClusterClusterCmd = &cobra.Command{ + Use: "cluster", + Short: "Describe workload clusters.", + Long: LongDesc(` + Provide an "at glance" view of a Cluster API cluster designed to help the user in quickly + understanding if there are problems and where. + .`), + + Example: Examples(` + # Describe the cluster named test-1. + clusterctl describe cluster test-1 + + # Describe the cluster named test-1 showing all the conditions for the KubeadmControlPlane object kind. + clusterctl describe cluster test-1 --show-conditions KubeadmControlPlane + + # Describe the cluster named test-1 showing all the conditions for a specific machine. + clusterctl describe cluster test-1 --show-conditions Machine/m1 + + # Describe the cluster named test-1 disabling automatic grouping of objects with the same ready condition + # e.g. un-group all the machines with Ready=true instead of showing a single group node. + clusterctl describe cluster test-1 --disable-grouping + + # Describe the cluster named test-1 disabling automatic echo suppression + # e.g. show the infrastructure machine objects, no matter if the current state is already reported by the machine's Ready condition. + clusterctl describe cluster test-1`), + + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDescribeCluster(args[0]) + }, +} + +func init() { + describeClusterClusterCmd.Flags().StringVar(&dc.kubeconfig, "kubeconfig", "", + "Path to a kubeconfig file to use for the management cluster. If empty, default discovery rules apply.") + describeClusterClusterCmd.Flags().StringVar(&dc.kubeconfigContext, "kubeconfig-context", "", + "Context to be used within the kubeconfig file. If empty, current context will be used.") + describeClusterClusterCmd.Flags().StringVarP(&dc.namespace, "namespace", "n", "", + "The namespace where the workload cluster is located. If unspecified, the current namespace will be used.") + + describeClusterClusterCmd.Flags().StringVar(&dc.showOtherConditions, "show-conditions", "", + " list of comma separated kind or kind/name for which the command should show all the object's conditions (use 'all' to show conditions for everything).") + describeClusterClusterCmd.Flags().BoolVar(&dc.disableNoEcho, "disable-no-echo", false, ""+ + "Disable hiding of a MachineInfrastructure and BootstrapConfig when ready condition is true or it has the Status, Severity and Reason of the machine's object.") + describeClusterClusterCmd.Flags().BoolVar(&dc.disableGrouping, "disable-grouping", false, + "Disable grouping machines when ready condition has the same Status, Severity and Reason.") + + describeCmd.AddCommand(describeClusterClusterCmd) +} + +func runDescribeCluster(name string) error { + c, err := client.New(cfgFile) + if err != nil { + return err + } + + tree, err := c.DescribeCluster(client.DescribeClusterOptions{ + Kubeconfig: client.Kubeconfig{Path: dc.kubeconfig, Context: dc.kubeconfigContext}, + Namespace: dc.namespace, + ClusterName: name, + ShowOtherConditions: dc.showOtherConditions, + DisableNoEcho: dc.disableNoEcho, + DisableGrouping: dc.disableGrouping, + }) + if err != nil { + return err + } + + printObjectTree(tree) + return nil +} + +// printObjectTree prints the cluster status to stdout +func printObjectTree(tree *tree.ObjectTree) { + // Creates the output table + tbl := uitable.New() + tbl.Separator = " " + tbl.AddRow("NAME", "READY", "SEVERITY", "REASON", "SINCE", "MESSAGE") + + // Add row for the root object, the cluster, and recursively for all the nodes representing the cluster status. + addObjectRow("", tbl, tree, tree.GetRoot()) + + // Prints the output table + fmt.Fprintln(color.Error, tbl) +} + +// 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 *uitable.Table, objectTree *tree.ObjectTree, obj ctrlclient.Object) { + // Gets the descriptor for the object's ready condition, if any. + readyDescriptor := conditionDescriptor{readyColor: gray} + if ready := tree.GetReadyCondition(obj); ready != nil { + readyDescriptor = newConditionDescriptor(ready) + } + + // 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 { + readyDescriptor.message = gray.Sprintf("See %s", strings.Join(items, tree.GroupItemsSeparator)) + } else { + readyDescriptor.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) + + // Add the row representing the object that includes + // - The row name with the tree view prefix. + // - The object's ready condition. + tbl.AddRow( + fmt.Sprintf("%s%s", gray.Sprint(prefix), name), + readyDescriptor.readyColor.Sprint(readyDescriptor.status), + readyDescriptor.readyColor.Sprint(readyDescriptor.severity), + readyDescriptor.readyColor.Sprint(readyDescriptor.reason), + readyDescriptor.age, + readyDescriptor.message) + + // If it is required to show all the conditions for the object, add a row for each object's conditions. + if tree.IsShowConditionsObject(obj) { + addOtherConditions(prefix, tbl, objectTree, obj) + } + + // Add a row for each object's children, taking care of updating the tree view prefix. + // NOTE: Children objects are sorted by row name for better readability. + childrenObj := objectTree.GetObjectsByParent(obj.GetUID()) + sort.Slice(childrenObj, func(i, j int) bool { + return getRowName(childrenObj[i]) < getRowName(childrenObj[j]) + }) + + for i, child := range childrenObj { + addObjectRow(getChildPrefix(prefix, i, len(childrenObj)), tbl, objectTree, child) + } +} + +// 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 *uitable.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 + } + + otherConditions := tree.GetOtherConditions(obj) + for i := range otherConditions { + otherCondition := otherConditions[i] + otherDescriptor := newConditionDescriptor(otherCondition) + otherConditionPrefix := getChildPrefix(prefix+childrenPipe+filler, i, len(otherConditions)) + tbl.AddRow( + fmt.Sprintf("%s%s", gray.Sprint(otherConditionPrefix), cyan.Sprint(otherCondition.Type)), + otherDescriptor.readyColor.Sprint(otherDescriptor.status), + otherDescriptor.readyColor.Sprint(otherDescriptor.severity), + otherDescriptor.readyColor.Sprint(otherDescriptor.reason), + otherDescriptor.age, + otherDescriptor.message) + } +} + +// getChildPrefix return the tree view prefix for a row representing a child object. +func getChildPrefix(currentPrefix string, childIndex, childCount int) string { + nextPrefix := currentPrefix + + // Alter the current prefix for hosting the next child object: + + // All ├─ should be replaced by |, so all the existing hierarchic dependencies are carried on + nextPrefix = strings.ReplaceAll(nextPrefix, firstElemPrefix, pipe) + // All └─ should be replaced by " " because we are under the last element of the tree (nothing to carry on) + nextPrefix = strings.ReplaceAll(nextPrefix, lastElemPrefix, strings.Repeat(" ", len([]rune(lastElemPrefix)))) + + // Add the prefix for the new child object (├─ for the firsts children, └─ for the last children). + if childIndex < childCount-1 { + return nextPrefix + firstElemPrefix + } + return nextPrefix + lastElemPrefix +} + +// 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 +// - objects with a meta name are represented as meta name - (kind/name), e.g. ClusterInfrastructure - DockerCluster/test1 +// - other objects are represented as kind/name, e.g.Machine/test1-md-0-779b87ff56-642vs +// - if the object is being deleted, a prefix will be added. +func getRowName(obj ctrlclient.Object) string { + if tree.IsGroupObject(obj) { + items := strings.Split(tree.GetGroupItems(obj), tree.GroupItemsSeparator) + kind := flect.Pluralize(strings.TrimSuffix(obj.GetObjectKind().GroupVersionKind().Kind, "Group")) + return white.Add(color.Bold).Sprintf("%d %s...", len(items), kind) + } + + if tree.IsVirtualObject(obj) { + return obj.GetName() + } + + objName := fmt.Sprintf("%s/%s", + obj.GetObjectKind().GroupVersionKind().Kind, + color.New(color.Bold).Sprint(obj.GetName())) + + name := objName + if objectPrefix := tree.GetMetaName(obj); objectPrefix != "" { + name = fmt.Sprintf("%s - %s", objectPrefix, gray.Sprintf(name)) + } + + if !obj.GetDeletionTimestamp().IsZero() { + name = fmt.Sprintf("%s %s", red.Sprintf("!! DELETED !!"), name) + } + + return name +} + +// conditionDescriptor contains all the info for representing a condition. +type conditionDescriptor struct { + readyColor *color.Color + age string + status string + severity string + reason string + message string +} + +// newConditionDescriptor returns a conditionDescriptor for the given condition. +func newConditionDescriptor(c *clusterv1.Condition) conditionDescriptor { + v := conditionDescriptor{} + + v.status = string(c.Status) + v.severity = string(c.Severity) + v.reason = c.Reason + v.message = c.Message + + // Eventually cut the message to keep the table dimension under control. + if len(v.message) > 100 { + v.message = fmt.Sprintf("%s ...", v.message[:100]) + } + + // Compute the condition age. + v.age = duration.HumanDuration(time.Since(c.LastTransitionTime.Time)) + + // Determine the color to be used for showing the conditions according to Status and Severity in case Status is false. + switch c.Status { + case corev1.ConditionTrue: + v.readyColor = green + case corev1.ConditionFalse, corev1.ConditionUnknown: + switch c.Severity { + case clusterv1.ConditionSeverityError: + v.readyColor = red + case clusterv1.ConditionSeverityWarning: + v.readyColor = yellow + default: + v.readyColor = white + } + default: + v.readyColor = gray + } + + return v +} diff --git a/cmd/clusterctl/cmd/describe_cluster_test.go b/cmd/clusterctl/cmd/describe_cluster_test.go new file mode 100644 index 000000000000..36a97be0c3bf --- /dev/null +++ b/cmd/clusterctl/cmd/describe_cluster_test.go @@ -0,0 +1,334 @@ +/* +Copyright 2019 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 cmd + +import ( + "fmt" + "strings" + "testing" + + "github.com/gosuri/uitable" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/cluster-api/util/conditions" + + "github.com/fatih/color" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/v1alpha4" + "sigs.k8s.io/cluster-api/cmd/clusterctl/client/tree" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +func Test_getRowName(t *testing.T) { + tests := []struct { + name string + object ctrlclient.Object + expect string + }{ + { + name: "Row name for objects should be kind/name", + object: fakeObject("c1"), + expect: "Object/c1", + }, + { + name: "Row name for a deleting object should have deleted prefix", + object: fakeObject("c1", withDeletionTimestamp), + expect: "!! DELETED !! Object/c1", + }, + { + name: "Row name for objects with meta name should be meta-name - kind/name", + object: fakeObject("c1", withAnnotation(tree.ObjectMetaNameAnnotation, "MetaName")), + expect: "MetaName - Object/c1", + }, + { + name: "Row name for virtual objects should be name", + object: fakeObject("c1", withAnnotation(tree.VirtualObjectAnnotation, "True")), + expect: "c1", + }, + { + name: "Row name for group objects should be #-of-items kind", + object: fakeObject("c1", + withAnnotation(tree.VirtualObjectAnnotation, "True"), + withAnnotation(tree.GroupObjectAnnotation, "True"), + withAnnotation(tree.GroupItemsAnnotation, "c1, c2, c3"), + ), + expect: "3 Objects...", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + got := getRowName(tt.object) + g.Expect(got).To(Equal(tt.expect)) + }) + } +} + +func Test_newConditionDescriptor_readyColor(t *testing.T) { + tests := []struct { + name string + condition *clusterv1.Condition + expectReadyColor *color.Color + }{ + { + name: "True condition should be green", + condition: conditions.TrueCondition("C"), + expectReadyColor: green, + }, + { + name: "Unknown condition should be white", + condition: conditions.UnknownCondition("C", "", ""), + expectReadyColor: white, + }, + { + name: "False condition, severity error should be red", + condition: conditions.FalseCondition("C", "", clusterv1.ConditionSeverityError, ""), + expectReadyColor: red, + }, + { + name: "False condition, severity warning should be yellow", + condition: conditions.FalseCondition("C", "", clusterv1.ConditionSeverityWarning, ""), + expectReadyColor: yellow, + }, + { + name: "False condition, severity info should be white", + condition: conditions.FalseCondition("C", "", clusterv1.ConditionSeverityInfo, ""), + expectReadyColor: white, + }, + { + name: "Condition without status should be gray", + condition: &clusterv1.Condition{}, + expectReadyColor: gray, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + got := newConditionDescriptor(tt.condition) + g.Expect(got.readyColor).To(Equal(tt.expectReadyColor)) + }) + } +} + +func Test_newConditionDescriptor_truncateMessages(t *testing.T) { + tests := []struct { + name string + condition *clusterv1.Condition + expectMessage string + }{ + { + name: "Short messages are not changed", + condition: conditions.UnknownCondition("C", "", "short message"), + expectMessage: "short message", + }, + { + name: "Long message are truncated", + condition: conditions.UnknownCondition("C", "", strings.Repeat("s", 150)), + expectMessage: fmt.Sprintf("%s ...", strings.Repeat("s", 100)), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + got := newConditionDescriptor(tt.condition) + g.Expect(got.message).To(Equal(tt.expectMessage)) + }) + } +} + +func Test_TreePrefix(t *testing.T) { + tests := []struct { + name string + objectTree *tree.ObjectTree + expectPrefix []string + }{ + { + name: "First level child should get the right prefix", + objectTree: func() *tree.ObjectTree { + root := fakeObject("root") + obectjTree := tree.NewObjectTree(root, tree.ObjectTreeOptions{}) + + o1 := fakeObject("child1") + o2 := fakeObject("child2") + obectjTree.Add(root, o1) + obectjTree.Add(root, o2) + return obectjTree + }(), + expectPrefix: []string{ + "Object/root", + "├─Object/child1", // first objects gets ├─ + "└─Object/child2", // last objects gets └─ + }, + }, + { + name: "Second level child should get the right prefix", + objectTree: func() *tree.ObjectTree { + root := fakeObject("root") + obectjTree := tree.NewObjectTree(root, tree.ObjectTreeOptions{}) + + o1 := fakeObject("child1") + o1_1 := fakeObject("child1.1") + o1_2 := fakeObject("child1.2") + o2 := fakeObject("child2") + o2_1 := fakeObject("child2.1") + o2_2 := fakeObject("child2.2") + + obectjTree.Add(root, o1) + obectjTree.Add(o1, o1_1) + obectjTree.Add(o1, o1_2) + obectjTree.Add(root, o2) + obectjTree.Add(o2, o2_1) + obectjTree.Add(o2, o2_2) + return obectjTree + }(), + expectPrefix: []string{ + "Object/root", + "├─Object/child1", + "│ ├─Object/child1.1", // first second level child gets pipes and ├─ + "│ └─Object/child1.2", // last second level child gets pipes and └─ + "└─Object/child2", + " ├─Object/child2.1", // first second level child spaces and ├─ + " └─Object/child2.2", // last second level child gets spaces and └─ + }, + }, + { + name: "Conditions should get the right prefix", + objectTree: func() *tree.ObjectTree { + root := fakeObject("root") + obectjTree := tree.NewObjectTree(root, tree.ObjectTreeOptions{}) + + o1 := fakeObject("child1", + withAnnotation(tree.ShowObjectConditionsAnnotation, "True"), + withCondition(conditions.TrueCondition("C1.1")), + withCondition(conditions.TrueCondition("C1.2")), + ) + o2 := fakeObject("child2", + withAnnotation(tree.ShowObjectConditionsAnnotation, "True"), + withCondition(conditions.TrueCondition("C2.1")), + withCondition(conditions.TrueCondition("C2.2")), + ) + obectjTree.Add(root, o1) + obectjTree.Add(root, o2) + return obectjTree + }(), + expectPrefix: []string{ + "Object/root", + "├─Object/child1", + "│ ├─C1.1", // first condition child gets pipes and ├─ + "│ └─C1.2", // last condition child gets └─ and pipes and └─ + "└─Object/child2", + " ├─C2.1", // first condition child gets spaces and ├─ + " └─C2.2", // last condition child gets spaces and └─ + }, + }, + { + name: "Conditions should get the right prefix if the object has a child", + objectTree: func() *tree.ObjectTree { + root := fakeObject("root") + obectjTree := tree.NewObjectTree(root, tree.ObjectTreeOptions{}) + + o1 := fakeObject("child1", + withAnnotation(tree.ShowObjectConditionsAnnotation, "True"), + withCondition(conditions.TrueCondition("C1.1")), + withCondition(conditions.TrueCondition("C1.2")), + ) + o1_1 := fakeObject("child1.1") + + o2 := fakeObject("child2", + withAnnotation(tree.ShowObjectConditionsAnnotation, "True"), + withCondition(conditions.TrueCondition("C2.1")), + withCondition(conditions.TrueCondition("C2.2")), + ) + o2_1 := fakeObject("child2.1") + obectjTree.Add(root, o1) + obectjTree.Add(o1, o1_1) + obectjTree.Add(root, o2) + obectjTree.Add(o2, o2_1) + return obectjTree + }(), + expectPrefix: []string{ + "Object/root", + "├─Object/child1", + "│ │ ├─C1.1", // first condition child gets pipes, children pipe and ├─ + "│ │ └─C1.2", // last condition child gets pipes, children pipe and └─ + "│ └─Object/child1.1", + "└─Object/child2", + " │ ├─C2.1", // first condition child gets spaces, children pipe and ├─ + " │ └─C2.2", // last condition child gets spaces, children pipe and └─ + " └─Object/child2.1", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // Creates the output table + tbl := uitable.New() + + // Add row for the root object, the cluster, and recursively for all the nodes representing the cluster status. + addObjectRow("", tbl, tt.objectTree, tt.objectTree.GetRoot()) + + for i := range tt.expectPrefix { + g.Expect(tbl.Rows[i].Cells[0].String()).To(Equal(tt.expectPrefix[i])) + } + + }) + } +} + +type objectOption func(object ctrlclient.Object) + +func fakeObject(name string, options ...objectOption) ctrlclient.Object { + c := &clusterv1.Cluster{ // suing type cluster for simplicity, but this could be any object + TypeMeta: metav1.TypeMeta{ + Kind: "Object", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns", + Name: name, + UID: types.UID(name), + }, + } + for _, opt := range options { + opt(c) + } + return c +} + +func withAnnotation(name, value string) func(ctrlclient.Object) { + return func(c ctrlclient.Object) { + if c.GetAnnotations() == nil { + c.SetAnnotations(map[string]string{}) + } + a := c.GetAnnotations() + a[name] = value + c.SetAnnotations(a) + } +} + +func withCondition(c *clusterv1.Condition) func(ctrlclient.Object) { + return func(m ctrlclient.Object) { + setter := m.(conditions.Setter) + conditions.Set(setter, c) + } +} + +func withDeletionTimestamp(object ctrlclient.Object) { + now := metav1.Now() + object.SetDeletionTimestamp(&now) +} diff --git a/go.mod b/go.mod index 103ff95141f8..91f2c8804ca8 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/docker/distribution v2.7.1+incompatible github.com/drone/envsubst v1.0.3-0.20200709223903-efdb65b94e5a github.com/evanphx/json-patch v4.9.0+incompatible + github.com/fatih/color v1.7.0 github.com/go-logr/logr v0.3.0 github.com/gobuffalo/flect v0.2.2 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e // indirect @@ -18,6 +19,7 @@ require ( github.com/google/go-querystring v1.0.0 // indirect github.com/google/gofuzz v1.2.0 github.com/google/uuid v1.1.2 // indirect + github.com/gosuri/uitable v0.0.4 github.com/imdario/mergo v0.3.11 // indirect github.com/onsi/ginkgo v1.14.1 github.com/onsi/gomega v1.10.2 diff --git a/go.sum b/go.sum index ad2617333687..238698e4ef99 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,7 @@ github.com/evanphx/json-patch/v5 v5.1.0 h1:B0aXl1o/1cP8NbviYiBMkcHBtUjIJ1/Ccg6b+ github.com/evanphx/json-patch/v5 v5.1.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= @@ -277,6 +278,8 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4 h1:z53tR0945TRRQO/fLEVPI6SMv7ZflF0TEaTAoU7tOzg= @@ -352,10 +355,12 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk= +github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= diff --git a/test/infrastructure/docker/go.sum b/test/infrastructure/docker/go.sum index fe6d76eca5cd..f229a8f65f63 100644 --- a/test/infrastructure/docker/go.sum +++ b/test/infrastructure/docker/go.sum @@ -250,6 +250,7 @@ github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2c github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=