Feat: vela status --tree (#3609)

* Feat: vela status --tree

Signed-off-by: Somefive <yd219913@alibaba-inc.com>

* Feat: support show not-deployed clusters

Signed-off-by: Somefive <yd219913@alibaba-inc.com>

* Fix: add tests

Signed-off-by: Somefive <yd219913@alibaba-inc.com>

* Fix: add multicluster e2e coverage

Signed-off-by: Somefive <yd219913@alibaba-inc.com>

* Chore: minor fix

Signed-off-by: Somefive <yd219913@alibaba-inc.com>
This commit is contained in:
Somefive
2022-04-12 17:58:45 +08:00
committed by GitHub
parent 7fcb89906c
commit 0e97aa2291
11 changed files with 564 additions and 52 deletions
+1 -1
View File
@@ -96,7 +96,7 @@ jobs:
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: /tmp/e2e-profile.out
files: /tmp/e2e-profile.out,/tmp/e2e_multicluster_test.out
flags: e2e-multicluster-test
name: codecov-umbrella
+2 -1
View File
@@ -7,4 +7,5 @@ coverage:
default:
target: 70%
ignore:
- "**/zz_generated.deepcopy.go"
- "**/zz_generated.deepcopy.go"
- "references/"
+13
View File
@@ -17,6 +17,8 @@ limitations under the License.
package oam
import (
"time"
"github.com/crossplane/crossplane-runtime/pkg/meta"
"sigs.k8s.io/controller-runtime/pkg/client"
)
@@ -49,3 +51,14 @@ func GetPublishVersion(o client.Object) string {
}
return ""
}
// GetLastAppliedTime .
func GetLastAppliedTime(o client.Object) time.Time {
if annotations := o.GetAnnotations(); annotations != nil {
s := annotations[AnnotationLastAppliedTime]
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
}
return o.GetCreationTimestamp().Time
}
+3
View File
@@ -120,6 +120,9 @@ const (
// resource for use in a three way diff during a patching apply
AnnotationLastAppliedConfig = "app.oam.dev/last-applied-configuration"
// AnnotationLastAppliedTime indicates the last applied time
AnnotationLastAppliedTime = "app.oam.dev/last-applied-time"
// AnnotationAppRollout indicates that the application is still rolling out
// the application controller should treat it differently
AnnotationAppRollout = "app.oam.dev/rollout-template"
+61
View File
@@ -17,10 +17,17 @@ limitations under the License.
package policy
import (
"context"
"github.com/pkg/errors"
utilfeature "k8s.io/apiserver/pkg/util/feature"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha1"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/pkg/features"
"github.com/oam-dev/kubevela/pkg/multicluster"
"github.com/oam-dev/kubevela/pkg/utils"
)
// GetClusterLabelSelectorInTopology get cluster label selector in topology policy spec
@@ -33,3 +40,57 @@ func GetClusterLabelSelectorInTopology(topology *v1alpha1.TopologyPolicySpec) ma
}
return nil
}
// GetPlacementsFromTopologyPolicies get placements from topology policies with provided client
func GetPlacementsFromTopologyPolicies(ctx context.Context, cli client.Client, app *v1beta1.Application, policies []v1beta1.AppPolicy, allowCrossNamespace bool) ([]v1alpha1.PlacementDecision, error) {
var placements []v1alpha1.PlacementDecision
placementMap := map[string]struct{}{}
addCluster := func(cluster string, ns string, validateCluster bool) error {
if validateCluster {
if _, e := multicluster.GetVirtualCluster(ctx, cli, cluster); e != nil {
return errors.Wrapf(e, "failed to get cluster %s", cluster)
}
}
if !allowCrossNamespace && (ns != app.GetNamespace() && ns != "") {
return errors.Errorf("cannot cross namespace")
}
placement := v1alpha1.PlacementDecision{Cluster: cluster, Namespace: ns}
name := placement.String()
if _, found := placementMap[name]; !found {
placementMap[name] = struct{}{}
placements = append(placements, placement)
}
return nil
}
for _, policy := range policies {
if policy.Type == v1alpha1.TopologyPolicyType {
topologySpec := &v1alpha1.TopologyPolicySpec{}
if err := utils.StrictUnmarshal(policy.Properties.Raw, topologySpec); err != nil {
return nil, errors.Wrapf(err, "failed to parse topology policy %s", policy.Name)
}
clusterLabelSelector := GetClusterLabelSelectorInTopology(topologySpec)
switch {
case topologySpec.Clusters != nil:
for _, cluster := range topologySpec.Clusters {
if err := addCluster(cluster, topologySpec.Namespace, true); err != nil {
return nil, err
}
}
case clusterLabelSelector != nil:
clusters, err := multicluster.FindVirtualClustersByLabels(context.Background(), cli, clusterLabelSelector)
if err != nil {
return nil, errors.Wrapf(err, "failed to find clusters in topology %s", policy.Name)
}
if len(clusters) == 0 {
return nil, errors.New("failed to find any cluster matches given labels")
}
for _, cluster := range clusters {
if err = addCluster(cluster.Name, topologySpec.Namespace, false); err != nil {
return nil, err
}
}
}
}
}
return placements, nil
}
+346
View File
@@ -0,0 +1,346 @@
/*
Copyright 2021 The KubeVela 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 resourcetracker
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
"github.com/fatih/color"
"github.com/gosuri/uitable"
"github.com/gosuri/uitable/util/strutil"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
apicommon "github.com/oam-dev/kubevela/apis/core.oam.dev/common"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha1"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/pkg/multicluster"
"github.com/oam-dev/kubevela/pkg/oam"
"github.com/oam-dev/kubevela/pkg/utils"
"github.com/oam-dev/kubevela/pkg/utils/common"
)
// ResourceDetailRetriever retriever to get details for resource
type ResourceDetailRetriever func(*resourceRow) error
// ResourceTreePrintOptions print options for resource tree
type ResourceTreePrintOptions struct {
DetailRetriever ResourceDetailRetriever
}
const (
resourceRowStatusUpdated = "updated"
resourceRowStatusNotDeployed = "not-deployed"
resourceRowStatusOutdated = "outdated"
)
type resourceRow struct {
mr *v1beta1.ManagedResource
status string
cluster string
namespace string
resourceName string
connectClusterUp bool
connectClusterDown bool
connectNamespaceUp bool
connectNamespaceDown bool
applyTime string
details string
}
func (options *ResourceTreePrintOptions) loadResourceRows(currentRT *v1beta1.ResourceTracker, historyRT []*v1beta1.ResourceTracker) []*resourceRow {
var rows []*resourceRow
if currentRT != nil {
for _, mr := range currentRT.Spec.ManagedResources {
if mr.Deleted {
continue
}
rows = append(rows, &resourceRow{
mr: mr.DeepCopy(),
status: resourceRowStatusUpdated,
})
}
}
for _, rt := range historyRT {
for _, mr := range rt.Spec.ManagedResources {
var matchedRow *resourceRow
for _, row := range rows {
if row.mr.ResourceKey() == mr.ResourceKey() {
matchedRow = row
}
}
if matchedRow == nil {
rows = append(rows, &resourceRow{
mr: mr.DeepCopy(),
status: resourceRowStatusOutdated,
})
}
}
}
return rows
}
func (options *ResourceTreePrintOptions) sortRows(rows []*resourceRow) {
sort.Slice(rows, func(i, j int) bool {
if rows[i].mr.Cluster != rows[j].mr.Cluster {
return rows[i].mr.Cluster < rows[j].mr.Cluster
}
if rows[i].mr.Namespace != rows[j].mr.Namespace {
return rows[i].mr.Namespace < rows[j].mr.Namespace
}
return rows[i].mr.ResourceKey() < rows[j].mr.ResourceKey()
})
}
func (options *ResourceTreePrintOptions) fillResourceRows(rows []*resourceRow, colsWidth []int) {
for i := 0; i < 4; i++ {
colsWidth[i] = 10
}
connectLastRow := func(rowIdx int, cluster bool, namespace bool) {
rows[rowIdx].connectClusterUp = cluster
rows[rowIdx-1].connectClusterDown = cluster
rows[rowIdx].connectNamespaceUp = namespace
rows[rowIdx-1].connectNamespaceDown = namespace
}
for rowIdx, row := range rows {
if row.mr.Cluster == "" {
row.mr.Cluster = multicluster.ClusterLocalName
}
if row.mr.Namespace == "" {
row.mr.Namespace = "-"
}
row.cluster, row.namespace, row.resourceName = row.mr.Cluster, row.mr.Namespace, fmt.Sprintf("%s/%s", row.mr.Kind, row.mr.Name)
if row.status == resourceRowStatusNotDeployed {
row.resourceName = "-"
}
if rowIdx > 0 && row.mr.Cluster == rows[rowIdx-1].mr.Cluster {
connectLastRow(rowIdx, true, false)
row.cluster = ""
if row.mr.Namespace == rows[rowIdx-1].mr.Namespace {
connectLastRow(rowIdx, true, true)
row.namespace = ""
}
}
for i, val := range []string{row.cluster, row.namespace, row.resourceName, row.status} {
if size := len(val) + 1; size > colsWidth[i] {
colsWidth[i] = size
}
}
}
for rowIdx := len(rows); rowIdx >= 1; rowIdx-- {
if rowIdx == len(rows) || rows[rowIdx].cluster != "" {
for j := rowIdx - 1; j >= 1; j-- {
if rows[j].cluster == "" && rows[j].namespace == "" {
connectLastRow(j, false, rows[j].connectNamespaceUp)
if j+1 < len(rows) {
connectLastRow(j+1, false, rows[j+1].connectNamespaceUp)
}
continue
}
break
}
}
}
// add extra spaces for tree connectors
colsWidth[0] += 4
colsWidth[1] += 4
}
func (options *ResourceTreePrintOptions) writeResourceTree(writer io.Writer, rows []*resourceRow, colsWidth []int) {
applyTimeWidth := 20
writePaddedString := func(sb *strings.Builder, head string, tail string, width int) {
sb.WriteString(head)
for c := strutil.StringWidth(head) + strutil.StringWidth(tail); c < width; c++ {
sb.WriteByte(' ')
}
sb.WriteString(tail)
}
var headerWriter strings.Builder
for colIdx, colName := range []string{"CLUSTER", "NAMESPACE", "RESOURCE", "STATUS"} {
writePaddedString(&headerWriter, colName, "", colsWidth[colIdx])
}
if options.DetailRetriever != nil {
writePaddedString(&headerWriter, "APPLY_TIME", "", applyTimeWidth)
_, _ = writer.Write([]byte(headerWriter.String() + "DETAIL" + "\n"))
} else {
_, _ = writer.Write([]byte(headerWriter.String() + "\n"))
}
connectorColorizer := color.WhiteString
outdatedColorizer := color.WhiteString
for _, row := range rows {
if options.DetailRetriever != nil && row.status != resourceRowStatusNotDeployed {
if err := options.DetailRetriever(row); err != nil {
row.details = "Error: " + err.Error()
}
}
for lineIdx, line := range strings.Split(row.details, "\n") {
var sb strings.Builder
rscName, rscStatus, applyTime := row.resourceName, row.status, row.applyTime
if row.status != resourceRowStatusUpdated {
rscName, rscStatus, applyTime, line = outdatedColorizer(row.resourceName), outdatedColorizer(row.status), outdatedColorizer(applyTime), outdatedColorizer(line)
}
if lineIdx == 0 {
writePaddedString(&sb, row.cluster, connectorColorizer(utils.GetBoxDrawingString(row.connectClusterUp, row.connectClusterDown, row.cluster != "", row.namespace != "", 1, 1))+" ", colsWidth[0])
writePaddedString(&sb, row.namespace, connectorColorizer(utils.GetBoxDrawingString(row.connectNamespaceUp, row.connectNamespaceDown, row.namespace != "", true, 1, 1))+" ", colsWidth[1])
writePaddedString(&sb, rscName, "", colsWidth[2])
writePaddedString(&sb, rscStatus, "", colsWidth[3])
} else {
writePaddedString(&sb, "", connectorColorizer(utils.GetBoxDrawingString(row.connectClusterDown, row.connectClusterDown, false, false, 1, 1))+" ", colsWidth[0])
writePaddedString(&sb, "", connectorColorizer(utils.GetBoxDrawingString(row.connectNamespaceDown, row.connectNamespaceDown, false, false, 1, 1))+" ", colsWidth[1])
writePaddedString(&sb, "", "", colsWidth[2])
writePaddedString(&sb, "", "", colsWidth[3])
}
if options.DetailRetriever != nil {
if lineIdx != 0 {
applyTime = ""
}
writePaddedString(&sb, applyTime, "", applyTimeWidth)
}
_, _ = writer.Write([]byte(sb.String() + line + "\n"))
}
}
}
func (options *ResourceTreePrintOptions) addNonExistingPlacementToRows(placements []v1alpha1.PlacementDecision, rows []*resourceRow) []*resourceRow {
existingClusters := map[string]struct{}{}
for _, row := range rows {
existingClusters[row.mr.Cluster] = struct{}{}
}
for _, p := range placements {
if _, found := existingClusters[p.Cluster]; !found {
rows = append(rows, &resourceRow{
mr: &v1beta1.ManagedResource{
ClusterObjectReference: apicommon.ClusterObjectReference{Cluster: p.Cluster},
},
status: resourceRowStatusNotDeployed,
})
}
}
return rows
}
// PrintResourceTree print resource tree to writer
func (options *ResourceTreePrintOptions) PrintResourceTree(writer io.Writer, currentPlacements []v1alpha1.PlacementDecision, currentRT *v1beta1.ResourceTracker, historyRT []*v1beta1.ResourceTracker) {
rows := options.loadResourceRows(currentRT, historyRT)
rows = options.addNonExistingPlacementToRows(currentPlacements, rows)
options.sortRows(rows)
colsWidth := make([]int, 4)
options.fillResourceRows(rows, colsWidth)
options.writeResourceTree(writer, rows, colsWidth)
}
type tableRoundTripper struct {
rt http.RoundTripper
}
// RoundTrip mutate the request header to let apiserver return table data
func (rt tableRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("Accept", strings.Join([]string{
fmt.Sprintf("application/json;as=Table;v=%s;g=%s", metav1.SchemeGroupVersion.Version, metav1.GroupName),
"application/json",
}, ","))
return rt.rt.RoundTrip(req)
}
// RetrieveKubeCtlGetMessageGenerator get details like kubectl get
func RetrieveKubeCtlGetMessageGenerator(cfg *rest.Config, format string) (ResourceDetailRetriever, error) {
cfg.Wrap(func(rt http.RoundTripper) http.RoundTripper {
return tableRoundTripper{rt: rt}
})
cli, err := client.New(cfg, client.Options{Scheme: common.Scheme})
if err != nil {
return nil, err
}
return func(row *resourceRow) error {
mr := row.mr
un := &unstructured.Unstructured{}
un.SetAPIVersion(mr.APIVersion)
un.SetKind(mr.Kind)
if err = cli.Get(multicluster.ContextWithClusterName(context.Background(), mr.Cluster), mr.NamespacedName(), un); err != nil {
return err
}
un.SetAPIVersion(metav1.SchemeGroupVersion.String())
un.SetKind("Table")
table := &metav1.Table{}
if err := runtime.DefaultUnstructuredConverter.FromUnstructured(un.Object, table); err != nil {
return err
}
obj := &unstructured.Unstructured{}
if err := json.Unmarshal(table.Rows[0].Object.Raw, obj); err == nil {
row.applyTime = oam.GetLastAppliedTime(obj).Format("2006-01-02 15:04:05")
}
switch format {
case "raw":
raw := table.Rows[0].Object.Raw
if annotations := obj.GetAnnotations(); annotations != nil && annotations[oam.AnnotationLastAppliedConfig] != "" {
raw = []byte(annotations[oam.AnnotationLastAppliedConfig])
}
bs, err := yaml.JSONToYAML(raw)
if err != nil {
return err
}
row.details = string(bs)
case "table":
tab := uitable.New()
var tabHeaders, tabValues []interface{}
for cid, column := range table.ColumnDefinitions {
if column.Name == "Name" || column.Name == "Created At" || column.Priority != 0 {
continue
}
tabHeaders = append(tabHeaders, column.Name)
tabValues = append(tabValues, table.Rows[0].Cells[cid])
}
tab.AddRow(tabHeaders...)
tab.AddRow(tabValues...)
row.details = tab.String()
default: // inline / wide / list
var entries []string
for cid, column := range table.ColumnDefinitions {
if column.Name == "Name" || column.Name == "Created At" || (format == "inline" && column.Priority != 0) {
continue
}
entries = append(entries, fmt.Sprintf("%s: %v", column.Name, table.Rows[0].Cells[cid]))
}
if format == "inline" || format == "wide" {
row.details = strings.Join(entries, " ")
} else {
row.details = strings.Join(entries, "\n")
}
}
return nil
}, nil
}
+2
View File
@@ -18,6 +18,7 @@ package apply
import (
"encoding/json"
"time"
"github.com/pkg/errors"
"k8s.io/apimachinery/pkg/api/meta"
@@ -147,6 +148,7 @@ func getModifiedConfiguration(obj runtime.Object, updateAnnotation bool) ([]byte
// restore original annotations back to the object
annots[oam.AnnotationLastAppliedConfig] = original
annots[oam.AnnotationLastAppliedTime] = time.Now().Format(time.RFC3339)
_ = metadataAccessor.SetAnnotations(obj, annots)
return modified, nil
}
+55
View File
@@ -19,6 +19,7 @@ package utils
import (
"reflect"
"sort"
"strings"
)
// StringsContain strings contain
@@ -85,3 +86,57 @@ func MapKey2Array(source map[string]string) []string {
}
return list
}
// GetBoxDrawingString get line drawing string, see https://en.wikipedia.org/wiki/Box-drawing_character
// nolint:gocyclo
func GetBoxDrawingString(up bool, down bool, left bool, right bool, padLeft int, padRight int) string {
var c rune
switch {
case up && down && left && right:
c = '┼'
case up && down && left && !right:
c = '┤'
case up && down && !left && right:
c = '├'
case up && down && !left && !right:
c = '│'
case up && !down && left && right:
c = '┴'
case up && !down && left && !right:
c = '┘'
case up && !down && !left && right:
c = '└'
case up && !down && !left && !right:
c = '╵'
case !up && down && left && right:
c = '┬'
case !up && down && left && !right:
c = '┐'
case !up && down && !left && right:
c = '┌'
case !up && down && !left && !right:
c = '╷'
case !up && !down && left && right:
c = '─'
case !up && !down && left && !right:
c = '╴'
case !up && !down && !left && right:
c = '╶'
case !up && !down && !left && !right:
c = ' '
}
sb := strings.Builder{}
writePadding := func(connect bool, width int) {
for i := 0; i < width; i++ {
if connect {
sb.WriteRune('─')
} else {
sb.WriteRune(' ')
}
}
}
writePadding(left, padLeft)
sb.WriteRune(c)
writePadding(right, padRight)
return sb.String()
}
@@ -168,58 +168,13 @@ func (p *provider) ExpandTopology(ctx wfContext.Context, v *value.Value, act wfT
if err != nil {
return err
}
policies := &[]*v1beta1.AppPolicy{}
policies := &[]v1beta1.AppPolicy{}
if err = policiesRaw.UnmarshalTo(policies); err != nil {
return errors.Wrapf(err, "failed to parse policies")
}
var placements []v1alpha1.PlacementDecision
placementMap := map[string]struct{}{}
addCluster := func(cluster string, ns string, validateCluster bool) error {
if validateCluster {
if _, e := multicluster.GetVirtualCluster(context.Background(), p, cluster); e != nil {
return errors.Wrapf(e, "failed to get cluster %s", cluster)
}
}
if !resourcekeeper.AllowCrossNamespaceResource && (ns != p.app.GetNamespace() && ns != "") {
return errors.Errorf("cannot cross namespace")
}
placement := v1alpha1.PlacementDecision{Cluster: cluster, Namespace: ns}
name := placement.String()
if _, found := placementMap[name]; !found {
placementMap[name] = struct{}{}
placements = append(placements, placement)
}
return nil
}
for _, policy := range *policies {
if policy.Type == v1alpha1.TopologyPolicyType {
topologySpec := &v1alpha1.TopologyPolicySpec{}
if err := utils.StrictUnmarshal(policy.Properties.Raw, topologySpec); err != nil {
return errors.Wrapf(err, "failed to parse topology policy %s", policy.Name)
}
clusterLabelSelector := pkgpolicy.GetClusterLabelSelectorInTopology(topologySpec)
switch {
case topologySpec.Clusters != nil:
for _, cluster := range topologySpec.Clusters {
if err := addCluster(cluster, topologySpec.Namespace, true); err != nil {
return err
}
}
case clusterLabelSelector != nil:
clusters, err := multicluster.FindVirtualClustersByLabels(context.Background(), p, clusterLabelSelector)
if err != nil {
return errors.Wrapf(err, "failed to find clusters in topology %s", policy.Name)
}
if len(clusters) == 0 {
return errors.Errorf("failed to find any cluster matches given labels")
}
for _, cluster := range clusters {
if err = addCluster(cluster.Name, topologySpec.Namespace, false); err != nil {
return err
}
}
}
}
placements, err := pkgpolicy.GetPlacementsFromTopologyPolicies(context.Background(), p, p.app, *policies, resourcekeeper.AllowCrossNamespaceResource)
if err != nil {
return err
}
return v.FillObject(placements, "outputs", "decisions")
}
+66 -1
View File
@@ -30,9 +30,15 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
commontypes "github.com/oam-dev/kubevela/apis/core.oam.dev/common"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha1"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha2"
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
"github.com/oam-dev/kubevela/apis/types"
pkgappfile "github.com/oam-dev/kubevela/pkg/appfile"
"github.com/oam-dev/kubevela/pkg/multicluster"
"github.com/oam-dev/kubevela/pkg/oam/discoverymapper"
"github.com/oam-dev/kubevela/pkg/policy"
"github.com/oam-dev/kubevela/pkg/resourcetracker"
"github.com/oam-dev/kubevela/pkg/utils/common"
cmdutil "github.com/oam-dev/kubevela/pkg/utils/util"
"github.com/oam-dev/kubevela/references/appfile"
@@ -103,6 +109,9 @@ func NewAppStatusCommand(c common.Args, order string, ioStreams cmdutil.IOStream
if err != nil {
return err
}
if printTree, err := cmd.Flags().GetBool("tree"); err == nil && printTree {
return printApplicationTree(c, cmd, appName, namespace)
}
newClient, err := c.GetClient()
if err != nil {
return err
@@ -125,8 +134,10 @@ func NewAppStatusCommand(c common.Args, order string, ioStreams cmdutil.IOStream
cmd.Flags().StringP("svc", "s", "", "service name")
cmd.Flags().BoolP("endpoint", "p", false, "show all service endpoints of the application")
cmd.Flags().StringP("component", "c", "", "filter service endpoints by component name")
cmd.Flags().BoolP("tree", "t", false, "display the application resources into tree structure")
cmd.Flags().BoolP("detail", "d", false, "display the realtime details of application resources")
cmd.Flags().StringP("detail-format", "", "inline", "the format for displaying details. Can be one of inline (default), wide, list, table, raw.")
addNamespaceAndEnvArg(cmd)
cmd.SetOut(ioStreams.Out)
return cmd
}
@@ -344,3 +355,57 @@ func getAppPhaseColor(appPhase commontypes.ApplicationPhase) *color.Color {
}
return yellow
}
func printApplicationTree(c common.Args, cmd *cobra.Command, appName string, appNs string) error {
config, err := c.GetConfig()
if err != nil {
return err
}
config.Wrap(multicluster.NewSecretModeMultiClusterRoundTripper)
cli, err := c.GetClient()
if err != nil {
return err
}
pd, err := c.GetPackageDiscover()
if err != nil {
return err
}
dm, err := discoverymapper.New(config)
if err != nil {
return err
}
app, err := loadRemoteApplication(cli, appNs, appName)
if err != nil {
return err
}
ctx := context.Background()
_, currentRT, historyRTs, _, err := resourcetracker.ListApplicationResourceTrackers(ctx, cli, app)
if err != nil {
return err
}
svc, err := multicluster.GetClusterGatewayService(context.Background(), cli)
if err != nil {
return errors.Wrapf(err, "failed to get cluster secret namespace, please ensure cluster gateway is correctly deployed")
}
multicluster.ClusterGatewaySecretNamespace = svc.Namespace
var placements []v1alpha1.PlacementDecision
af, err := pkgappfile.NewApplicationParser(cli, dm, pd).GenerateAppFile(context.Background(), app)
if err == nil {
placements, _ = policy.GetPlacementsFromTopologyPolicies(context.Background(), cli, app, af.Policies, true)
}
options := resourcetracker.ResourceTreePrintOptions{}
printDetails, _ := cmd.Flags().GetBool("detail")
format, _ := cmd.Flags().GetString("detail-format")
if printDetails {
msgRetriever, err := resourcetracker.RetrieveKubeCtlGetMessageGenerator(config, format)
if err != nil {
return err
}
options.DetailRetriever = msgRetriever
}
options.PrintResourceTree(cmd.OutOrStdout(), placements, currentRT, historyRTs)
return nil
}
@@ -101,6 +101,17 @@ var _ = Describe("Test multicluster CLI commands", func() {
Expect(string(bs)).Should(ContainSubstring("Hello World"))
})
It("Test vela status --tree", func() {
for _, format := range []string{"inline", "wide", "table", "list"} {
outputs, err := execCommand("status", app.Name, "-n", namespace, "--tree", "--detail", "--detail-format", format)
Expect(err).Should(Succeed())
Expect(string(outputs)).Should(SatisfyAll(
ContainSubstring("Deployment/exec-podinfo"),
ContainSubstring("updated"),
ContainSubstring("1/1"),
))
}
})
})
})