github.com/oam-dev/kubevela@v1.9.11/pkg/resourcetracker/tree.go (about)

     1  /*
     2  Copyright 2021 The KubeVela Authors.
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8  	http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  package resourcetracker
    18  
    19  import (
    20  	"context"
    21  	"encoding/json"
    22  	"fmt"
    23  	"io"
    24  	"math"
    25  	"net/http"
    26  	"sort"
    27  	"strings"
    28  
    29  	"github.com/fatih/color"
    30  	"github.com/gosuri/uitable"
    31  	"github.com/gosuri/uitable/util/strutil"
    32  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    33  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    34  	"k8s.io/apimachinery/pkg/runtime"
    35  	"k8s.io/client-go/rest"
    36  	"sigs.k8s.io/controller-runtime/pkg/client"
    37  	"sigs.k8s.io/yaml"
    38  
    39  	apicommon "github.com/oam-dev/kubevela/apis/core.oam.dev/common"
    40  	"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha1"
    41  	"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
    42  	"github.com/oam-dev/kubevela/pkg/multicluster"
    43  	"github.com/oam-dev/kubevela/pkg/oam"
    44  	"github.com/oam-dev/kubevela/pkg/utils"
    45  	"github.com/oam-dev/kubevela/pkg/utils/common"
    46  )
    47  
    48  // ResourceDetailRetriever retriever to get details for resource
    49  type ResourceDetailRetriever func(*resourceRow, string) error
    50  
    51  // ResourceTreePrintOptions print options for resource tree
    52  type ResourceTreePrintOptions struct {
    53  	DetailRetriever ResourceDetailRetriever
    54  	multicluster.ClusterNameMapper
    55  	// MaxWidth if set, the detail part will auto wrap
    56  	MaxWidth *int
    57  	// Format for details
    58  	Format string
    59  }
    60  
    61  const (
    62  	resourceRowStatusUpdated     = "updated"
    63  	resourceRowStatusNotDeployed = "not-deployed"
    64  	resourceRowStatusOutdated    = "outdated"
    65  )
    66  
    67  type resourceRow struct {
    68  	mr                   *v1beta1.ManagedResource
    69  	status               string
    70  	cluster              string
    71  	namespace            string
    72  	resourceName         string
    73  	connectClusterUp     bool
    74  	connectClusterDown   bool
    75  	connectNamespaceUp   bool
    76  	connectNamespaceDown bool
    77  	applyTime            string
    78  	details              string
    79  }
    80  
    81  func (options *ResourceTreePrintOptions) loadResourceRows(currentRT *v1beta1.ResourceTracker, historyRT []*v1beta1.ResourceTracker) []*resourceRow {
    82  	var rows []*resourceRow
    83  	if currentRT != nil {
    84  		for _, mr := range currentRT.Spec.ManagedResources {
    85  			if mr.Deleted {
    86  				continue
    87  			}
    88  			rows = append(rows, buildResourceRow(mr, resourceRowStatusUpdated))
    89  		}
    90  	}
    91  	for _, rt := range historyRT {
    92  		for _, mr := range rt.Spec.ManagedResources {
    93  			var matchedRow *resourceRow
    94  			for _, row := range rows {
    95  				if row.mr.ResourceKey() == mr.ResourceKey() {
    96  					matchedRow = row
    97  				}
    98  			}
    99  			if matchedRow == nil {
   100  				rows = append(rows, buildResourceRow(mr, resourceRowStatusOutdated))
   101  			}
   102  		}
   103  	}
   104  	return rows
   105  }
   106  
   107  func (options *ResourceTreePrintOptions) sortRows(rows []*resourceRow) {
   108  	sort.Slice(rows, func(i, j int) bool {
   109  		if rows[i].mr.Cluster != rows[j].mr.Cluster {
   110  			return rows[i].mr.Cluster < rows[j].mr.Cluster
   111  		}
   112  		if rows[i].mr.Namespace != rows[j].mr.Namespace {
   113  			return rows[i].mr.Namespace < rows[j].mr.Namespace
   114  		}
   115  		return rows[i].mr.ResourceKey() < rows[j].mr.ResourceKey()
   116  	})
   117  }
   118  
   119  func (options *ResourceTreePrintOptions) fillResourceRows(rows []*resourceRow, colsWidth []int) {
   120  	for i := 0; i < 4; i++ {
   121  		colsWidth[i] = 10
   122  	}
   123  	connectLastRow := func(rowIdx int, cluster bool, namespace bool) {
   124  		rows[rowIdx].connectClusterUp = cluster
   125  		rows[rowIdx-1].connectClusterDown = cluster
   126  		rows[rowIdx].connectNamespaceUp = namespace
   127  		rows[rowIdx-1].connectNamespaceDown = namespace
   128  	}
   129  	for rowIdx, row := range rows {
   130  		if row.mr.Cluster == "" {
   131  			row.mr.Cluster = multicluster.ClusterLocalName
   132  		}
   133  		if row.mr.Namespace == "" {
   134  			row.mr.Namespace = "-"
   135  		}
   136  		row.cluster, row.namespace, row.resourceName = options.ClusterNameMapper.GetClusterName(row.mr.Cluster), row.mr.Namespace, fmt.Sprintf("%s/%s", row.mr.Kind, row.mr.Name)
   137  		if row.status == resourceRowStatusNotDeployed {
   138  			row.resourceName = "-"
   139  		}
   140  		if rowIdx > 0 && row.mr.Cluster == rows[rowIdx-1].mr.Cluster {
   141  			connectLastRow(rowIdx, true, false)
   142  			row.cluster = ""
   143  			if row.mr.Namespace == rows[rowIdx-1].mr.Namespace {
   144  				connectLastRow(rowIdx, true, true)
   145  				row.namespace = ""
   146  			}
   147  		}
   148  		for i, val := range []string{row.cluster, row.namespace, row.resourceName, row.status} {
   149  			if size := len(val) + 1; size > colsWidth[i] {
   150  				colsWidth[i] = size
   151  			}
   152  		}
   153  	}
   154  	for rowIdx := len(rows); rowIdx >= 1; rowIdx-- {
   155  		if rowIdx == len(rows) || rows[rowIdx].cluster != "" {
   156  			for j := rowIdx - 1; j >= 1; j-- {
   157  				if rows[j].cluster == "" && rows[j].namespace == "" {
   158  					connectLastRow(j, false, rows[j].connectNamespaceUp)
   159  					if j+1 < len(rows) {
   160  						connectLastRow(j+1, false, rows[j+1].connectNamespaceUp)
   161  					}
   162  					continue
   163  				}
   164  				break
   165  			}
   166  		}
   167  	}
   168  
   169  	// add extra spaces for tree connectors
   170  	colsWidth[0] += 4
   171  	colsWidth[1] += 4
   172  }
   173  
   174  const (
   175  	applyTimeWidth = 20
   176  	detailMinWidth = 20
   177  )
   178  
   179  func (options *ResourceTreePrintOptions) _getWidthForDetails(colsWidth []int) int {
   180  	detailWidth := 0
   181  	if options.MaxWidth == nil {
   182  		return math.MaxInt
   183  	}
   184  	detailWidth = *options.MaxWidth - applyTimeWidth
   185  	for _, width := range colsWidth {
   186  		detailWidth -= width
   187  	}
   188  	// if the space for details exceeds the max allowed width, give up wrapping lines
   189  	if detailWidth < detailMinWidth {
   190  		detailWidth = math.MaxInt
   191  	}
   192  	return detailWidth
   193  }
   194  
   195  func (options *ResourceTreePrintOptions) _wrapDetails(detail string, width int) (lines []string) {
   196  	for _, row := range strings.Split(detail, "\n") {
   197  		var sb strings.Builder
   198  		row = strings.ReplaceAll(row, "\t", " ")
   199  		sep := "  "
   200  		if options.Format == "raw" {
   201  			sep = "\n"
   202  		}
   203  		for _, token := range strings.Split(row, sep) {
   204  			if sb.Len()+len(token)+2 <= width {
   205  				if sb.Len() > 0 {
   206  					sb.WriteString(sep)
   207  				}
   208  				sb.WriteString(token)
   209  			} else {
   210  				if sb.Len() > 0 {
   211  					lines = append(lines, sb.String())
   212  					sb.Reset()
   213  				}
   214  				offset := 0
   215  				for {
   216  					if offset+width > len(token) {
   217  						break
   218  					}
   219  					lines = append(lines, token[offset:offset+width])
   220  					offset += width
   221  				}
   222  				sb.WriteString(token[offset:])
   223  			}
   224  		}
   225  		if sb.Len() > 0 {
   226  			lines = append(lines, sb.String())
   227  		}
   228  	}
   229  	if len(lines) == 0 {
   230  		lines = []string{""}
   231  	}
   232  	return lines
   233  }
   234  
   235  func (options *ResourceTreePrintOptions) writeResourceTree(writer io.Writer, rows []*resourceRow, colsWidth []int) {
   236  	writePaddedString := func(sb *strings.Builder, head string, tail string, width int) {
   237  		sb.WriteString(head)
   238  		for c := strutil.StringWidth(head) + strutil.StringWidth(tail); c < width; c++ {
   239  			sb.WriteByte(' ')
   240  		}
   241  		sb.WriteString(tail)
   242  	}
   243  
   244  	var headerWriter strings.Builder
   245  	for colIdx, colName := range []string{"CLUSTER", "NAMESPACE", "RESOURCE", "STATUS"} {
   246  		writePaddedString(&headerWriter, colName, "", colsWidth[colIdx])
   247  	}
   248  	if options.DetailRetriever != nil {
   249  		writePaddedString(&headerWriter, "APPLY_TIME", "", applyTimeWidth)
   250  		_, _ = writer.Write([]byte(headerWriter.String() + "DETAIL" + "\n"))
   251  	} else {
   252  		_, _ = writer.Write([]byte(headerWriter.String() + "\n"))
   253  	}
   254  
   255  	connectorColorizer := color.WhiteString
   256  	outdatedColorizer := color.WhiteString
   257  	detailWidth := options._getWidthForDetails(colsWidth)
   258  
   259  	for _, row := range rows {
   260  		if options.DetailRetriever != nil && row.status != resourceRowStatusNotDeployed {
   261  			if err := options.DetailRetriever(row, options.Format); err != nil {
   262  				row.details = "Error: " + err.Error()
   263  			}
   264  		}
   265  		for lineIdx, line := range options._wrapDetails(row.details, detailWidth) {
   266  			var sb strings.Builder
   267  			rscName, rscStatus, applyTime := row.resourceName, row.status, row.applyTime
   268  			if row.status != resourceRowStatusUpdated {
   269  				rscName, rscStatus, applyTime, line = outdatedColorizer(row.resourceName), outdatedColorizer(row.status), outdatedColorizer(applyTime), outdatedColorizer(line)
   270  			}
   271  			if lineIdx == 0 {
   272  				writePaddedString(&sb, row.cluster, connectorColorizer(utils.GetBoxDrawingString(row.connectClusterUp, row.connectClusterDown, row.cluster != "", row.namespace != "", 1, 1))+" ", colsWidth[0])
   273  				writePaddedString(&sb, row.namespace, connectorColorizer(utils.GetBoxDrawingString(row.connectNamespaceUp, row.connectNamespaceDown, row.namespace != "", true, 1, 1))+" ", colsWidth[1])
   274  				writePaddedString(&sb, rscName, "", colsWidth[2])
   275  				writePaddedString(&sb, rscStatus, "", colsWidth[3])
   276  			} else {
   277  				writePaddedString(&sb, "", connectorColorizer(utils.GetBoxDrawingString(row.connectClusterDown, row.connectClusterDown, false, false, 1, 1))+" ", colsWidth[0])
   278  				writePaddedString(&sb, "", connectorColorizer(utils.GetBoxDrawingString(row.connectNamespaceDown, row.connectNamespaceDown, false, false, 1, 1))+" ", colsWidth[1])
   279  				writePaddedString(&sb, "", "", colsWidth[2])
   280  				writePaddedString(&sb, "", "", colsWidth[3])
   281  			}
   282  
   283  			if options.DetailRetriever != nil {
   284  				if lineIdx != 0 {
   285  					applyTime = ""
   286  				}
   287  				writePaddedString(&sb, applyTime, "", applyTimeWidth)
   288  			}
   289  			_, _ = writer.Write([]byte(sb.String() + line + "\n"))
   290  		}
   291  	}
   292  }
   293  
   294  func (options *ResourceTreePrintOptions) addNonExistingPlacementToRows(placements []v1alpha1.PlacementDecision, rows []*resourceRow) []*resourceRow {
   295  	existingClusters := map[string]struct{}{}
   296  	for _, row := range rows {
   297  		existingClusters[row.mr.Cluster] = struct{}{}
   298  	}
   299  	for _, p := range placements {
   300  		if _, found := existingClusters[p.Cluster]; !found {
   301  			rows = append(rows, &resourceRow{
   302  				mr: &v1beta1.ManagedResource{
   303  					ClusterObjectReference: apicommon.ClusterObjectReference{Cluster: p.Cluster},
   304  				},
   305  				status: resourceRowStatusNotDeployed,
   306  			})
   307  		}
   308  	}
   309  	return rows
   310  }
   311  
   312  // PrintResourceTree print resource tree to writer
   313  func (options *ResourceTreePrintOptions) PrintResourceTree(writer io.Writer, currentPlacements []v1alpha1.PlacementDecision, currentRT *v1beta1.ResourceTracker, historyRT []*v1beta1.ResourceTracker) {
   314  	rows := options.loadResourceRows(currentRT, historyRT)
   315  	rows = options.addNonExistingPlacementToRows(currentPlacements, rows)
   316  	options.sortRows(rows)
   317  
   318  	colsWidth := make([]int, 4)
   319  	options.fillResourceRows(rows, colsWidth)
   320  
   321  	options.writeResourceTree(writer, rows, colsWidth)
   322  }
   323  
   324  type tableRoundTripper struct {
   325  	rt http.RoundTripper
   326  }
   327  
   328  // RoundTrip mutate the request header to let apiserver return table data
   329  func (rt tableRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
   330  	req.Header.Set("Accept", strings.Join([]string{
   331  		fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName),
   332  		"application/json",
   333  	}, ","))
   334  	return rt.rt.RoundTrip(req)
   335  }
   336  
   337  // RetrieveKubeCtlGetMessageGenerator get details like kubectl get
   338  func RetrieveKubeCtlGetMessageGenerator(cfg *rest.Config) (ResourceDetailRetriever, error) {
   339  	cfg.Wrap(func(rt http.RoundTripper) http.RoundTripper {
   340  		return tableRoundTripper{rt: rt}
   341  	})
   342  	cli, err := client.New(cfg, client.Options{Scheme: common.Scheme})
   343  	if err != nil {
   344  		return nil, err
   345  	}
   346  	return func(row *resourceRow, format string) error {
   347  		mr := row.mr
   348  		un := &unstructured.Unstructured{}
   349  		un.SetAPIVersion(mr.APIVersion)
   350  		un.SetKind(mr.Kind)
   351  		if err = cli.Get(multicluster.ContextWithClusterName(context.Background(), mr.Cluster), mr.NamespacedName(), un); err != nil {
   352  			return err
   353  		}
   354  		un.SetAPIVersion(metav1.SchemeGroupVersion.String())
   355  		un.SetKind("Table")
   356  		table := &metav1.Table{}
   357  		if err := runtime.DefaultUnstructuredConverter.FromUnstructured(un.Object, table); err != nil {
   358  			return err
   359  		}
   360  
   361  		obj := &unstructured.Unstructured{}
   362  		if err := json.Unmarshal(table.Rows[0].Object.Raw, obj); err == nil {
   363  			row.applyTime = oam.GetLastAppliedTime(obj).Format("2006-01-02 15:04:05")
   364  		}
   365  
   366  		switch format {
   367  		case "raw":
   368  			raw := table.Rows[0].Object.Raw
   369  			if annotations := obj.GetAnnotations(); annotations != nil && annotations[oam.AnnotationLastAppliedConfig] != "" {
   370  				raw = []byte(annotations[oam.AnnotationLastAppliedConfig])
   371  			}
   372  			bs, err := yaml.JSONToYAML(raw)
   373  			if err != nil {
   374  				return err
   375  			}
   376  			row.details = string(bs)
   377  		case "table":
   378  			tab := uitable.New()
   379  			var tabHeaders, tabValues []interface{}
   380  			for cid, column := range table.ColumnDefinitions {
   381  				if column.Name == "Name" || column.Name == "Created At" || column.Priority != 0 {
   382  					continue
   383  				}
   384  				tabHeaders = append(tabHeaders, column.Name)
   385  				tabValues = append(tabValues, table.Rows[0].Cells[cid])
   386  			}
   387  			tab.AddRow(tabHeaders...)
   388  			tab.AddRow(tabValues...)
   389  			row.details = tab.String()
   390  		default: // inline / wide / list
   391  			var entries []string
   392  			for cid, column := range table.ColumnDefinitions {
   393  				if column.Name == "Name" || column.Name == "Created At" || (format == "inline" && column.Priority != 0) {
   394  					continue
   395  				}
   396  				entries = append(entries, fmt.Sprintf("%s: %v", column.Name, table.Rows[0].Cells[cid]))
   397  			}
   398  			if format == "inline" || format == "wide" {
   399  				row.details = strings.Join(entries, "  ")
   400  			} else {
   401  				row.details = strings.Join(entries, "\n")
   402  			}
   403  		}
   404  		return nil
   405  	}, nil
   406  }
   407  
   408  func buildResourceRow(mr v1beta1.ManagedResource, resourceStatus string) *resourceRow {
   409  	rr := &resourceRow{
   410  		mr:     mr.DeepCopy(),
   411  		status: resourceStatus,
   412  	}
   413  	if rr.mr.Cluster == "" {
   414  		rr.mr.Cluster = multicluster.ClusterLocalName
   415  	}
   416  	return rr
   417  }