diff --git a/.github/workflows/e2e-multicluster-test.yml b/.github/workflows/e2e-multicluster-test.yml index 66cd96932..85e9fcc8c 100644 --- a/.github/workflows/e2e-multicluster-test.yml +++ b/.github/workflows/e2e-multicluster-test.yml @@ -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 diff --git a/codecov.yml b/codecov.yml index 3e6943c41..a6e69fb33 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,4 +7,5 @@ coverage: default: target: 70% ignore: - - "**/zz_generated.deepcopy.go" \ No newline at end of file + - "**/zz_generated.deepcopy.go" + - "references/" diff --git a/pkg/oam/auxiliary.go b/pkg/oam/auxiliary.go index 62a4dd32c..1e717ca55 100644 --- a/pkg/oam/auxiliary.go +++ b/pkg/oam/auxiliary.go @@ -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 +} diff --git a/pkg/oam/labels.go b/pkg/oam/labels.go index 01acd49c5..3d35ce20b 100644 --- a/pkg/oam/labels.go +++ b/pkg/oam/labels.go @@ -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" diff --git a/pkg/policy/topology.go b/pkg/policy/topology.go index d84971bcd..badc3ecf4 100644 --- a/pkg/policy/topology.go +++ b/pkg/policy/topology.go @@ -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 +} diff --git a/pkg/resourcetracker/tree.go b/pkg/resourcetracker/tree.go new file mode 100644 index 000000000..ab73e7430 --- /dev/null +++ b/pkg/resourcetracker/tree.go @@ -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 +} diff --git a/pkg/utils/apply/patch.go b/pkg/utils/apply/patch.go index 5aa1af45c..dc2f0eb6e 100644 --- a/pkg/utils/apply/patch.go +++ b/pkg/utils/apply/patch.go @@ -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 } diff --git a/pkg/utils/strings.go b/pkg/utils/strings.go index aa1e03519..88c4f9def 100644 --- a/pkg/utils/strings.go +++ b/pkg/utils/strings.go @@ -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() +} diff --git a/pkg/workflow/providers/multicluster/multicluster.go b/pkg/workflow/providers/multicluster/multicluster.go index 294581e22..fc919b0dd 100644 --- a/pkg/workflow/providers/multicluster/multicluster.go +++ b/pkg/workflow/providers/multicluster/multicluster.go @@ -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") } diff --git a/references/cli/status.go b/references/cli/status.go index e8a408787..f0419b124 100644 --- a/references/cli/status.go +++ b/references/cli/status.go @@ -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 +} diff --git a/test/e2e-multicluster-test/multicluster_cli_test.go b/test/e2e-multicluster-test/multicluster_cli_test.go index d9d1154b0..dfd0f8a25 100644 --- a/test/e2e-multicluster-test/multicluster_cli_test.go +++ b/test/e2e-multicluster-test/multicluster_cli_test.go @@ -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"), + )) + } + }) }) })