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 }