Repository: openshift/osdctl
Pull request #712: [OSD-28483] Add an `osdctl dt dash` command
Author: login: smarthall
Labels: name: lgtm
description: Indicates that a PR is ready to be merged.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
Created: 2025-04-02T05:00:51Z | Merged: 2025-04-07T11:42:49Z

This command allows you to find the overview dashboard for a cluster in the right tenant for the cluster in question. The cluster's external ID id passed to the cluster as it `$_id` variable (if it has one).

You can also override the dashboard name to see another dashboard.

Example (sanitised) Execution:

```sh
$ osdctl dt dash --dash "Example Dashboard name" --cluster-id 00000000-0000-0000-0000-000000000000
Successfully authenticated with DynaTrace


Dynatrace Environment URL:
  https://xxx00000.apps.dynatrace.com/ui/apps/dynatrace.dashboards/dashboard/11111111-1111-1111-1111-111111111111#vfilter__id=00000000-0000-0000-0000-000000000000
```


Diff:
diff --git a/cmd/dynatrace/cluster.go b/cmd/dynatrace/cluster.go
index 7933398f8..76582a255 100644
--- a/cmd/dynatrace/cluster.go
+++ b/cmd/dynatrace/cluster.go
@@ -18,6 +18,7 @@ import (
 type HCPCluster struct {
 	name                  string
 	internalID            string
+	externalID            string
 	managementClusterID   string
 	klusterletNS          string
 	hostedNS              string
@@ -86,6 +87,7 @@ func FetchClusterDetails(clusterKey string) (hcpCluster HCPCluster, error error)
 
 	hcpCluster.DynatraceURL = url
 	hcpCluster.internalID = cluster.ID()
+	hcpCluster.externalID = cluster.ExternalID()
 	hcpCluster.managementClusterID = mgmtCluster.ID()
 	hcpCluster.name = cluster.Name()
 	hcpCluster.managementClusterName = mgmtCluster.Name()
diff --git a/cmd/dynatrace/dashCmd.go b/cmd/dynatrace/dashCmd.go
new file mode 100644
index 000000000..c85e9f041
--- /dev/null
+++ b/cmd/dynatrace/dashCmd.go
@@ -0,0 +1,53 @@
+package dynatrace
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	cmdutil "k8s.io/kubectl/pkg/cmd/util"
+)
+
+var (
+	dashboardName string
+	clusterId     string
+)
+
+func newCmdDashboard() *cobra.Command {
+	urlCmd := &cobra.Command{
+		Use:               "dashboard --cluster-id CLUSTER_ID",
+		Aliases:           []string{"dash"},
+		Short:             "Get the Dyntrace Cluster Overview Dashboard for a given MC or HCP cluster",
+		DisableAutoGenTag: true,
+		Run: func(cmd *cobra.Command, args []string) {
+			// We need the Dynatrace URL
+			hcpCluster, err := FetchClusterDetails(clusterId)
+			if err != nil {
+				cmdutil.CheckErr(err)
+			}
+
+			// Get credentials
+			accessToken, err := getDocumentAccessToken()
+			if err != nil {
+				fmt.Printf("Could not get access token %s\n", err)
+				return
+			}
+
+			// Search for the dashboard
+			id, err := getDocumentIDByNameAndType(hcpCluster.DynatraceURL, accessToken, dashboardName, DTDashboardType)
+			if err != nil {
+				fmt.Printf("Could not find dashboard named '%s': %s\n", dashboardName, err)
+				return
+			}
+
+			// Tell the user
+			dashUrl := hcpCluster.DynatraceURL + "ui/apps/dynatrace.dashboards/dashboard/" + id + "#vfilter__id=" + hcpCluster.externalID
+			fmt.Printf("\n\nDashboard URL:\n  %s\n", dashUrl)
+		},
+	}
+
+	urlCmd.Flags().StringVar(&dashboardName, "dash", "Central ROSA HCP Dashboard", "Name of the dashboard you wish to find")
+	urlCmd.Flags().StringVar(&clusterId, "cluster-id", "", "Provide the id of the cluster")
+	_ = urlCmd.MarkFlagRequired("cluster-id")
+
+	return urlCmd
+}
diff --git a/cmd/dynatrace/hcpGatherLogsCmd.go b/cmd/dynatrace/hcpGatherLogsCmd.go
index d99ebb73e..14001609c 100644
--- a/cmd/dynatrace/hcpGatherLogsCmd.go
+++ b/cmd/dynatrace/hcpGatherLogsCmd.go
@@ -58,7 +58,7 @@ func NewCmdHCPMustGather() *cobra.Command {
 }
 
 func (g *GatherLogsOpts) GatherLogs(clusterID string) (error error) {
-	accessToken, err := getAccessToken()
+	accessToken, err := getStorageAccessToken()
 	if err != nil {
 		return fmt.Errorf("failed to acquire access token %v", err)
 	}
diff --git a/cmd/dynatrace/logsCmd.go b/cmd/dynatrace/logsCmd.go
index d8ac64feb..793662e10 100644
--- a/cmd/dynatrace/logsCmd.go
+++ b/cmd/dynatrace/logsCmd.go
@@ -174,7 +174,7 @@ func main(clusterID string) error {
 		return nil
 	}
 
-	accessToken, err := getAccessToken()
+	accessToken, err := getStorageAccessToken()
 	if err != nil {
 		return fmt.Errorf("failed to acquire access token %v", err)
 	}
diff --git a/cmd/dynatrace/requests.go b/cmd/dynatrace/requests.go
index 01c8f353d..2f1c13473 100644
--- a/cmd/dynatrace/requests.go
+++ b/cmd/dynatrace/requests.go
@@ -13,9 +13,18 @@ import (
 )
 
 const (
-	authURL     string = "https://sso.dynatrace.com/sso/oauth2/token"
-	DTVaultPath string = "dt_vault_path"
-	VaultAddr   string = "vault_address"
+	VaultAddr string = "vault_address"
+
+	authURL string = "https://sso.dynatrace.com/sso/oauth2/token"
+
+	// Logs
+	DTStorageVaultPath string = "dt_vault_path"
+	DTStorageScopes    string = "storage:logs:read storage:events:read storage:buckets:read"
+
+	// Dashboards
+	DTDocumentVaultPath string = "dt_document_vault_path"
+	DTDocumentScopes    string = "document:documents:read"
+	DTDashboardType     string = "dashboard"
 )
 
 type DTRequestError struct {
@@ -76,40 +85,42 @@ func (rh *Requester) send() (string, error) {
 	return string(body), nil
 }
 
-func getVaultPath() (addr, path string, error error) {
+func getVaultPath(vaultPathKey string) (addr, path string, error error) {
 	if !viper.IsSet(VaultAddr) {
-		return "", "", fmt.Errorf("key %s is not set in config file", VaultAddr)
+		return "", "", fmt.Errorf("key '%s' is not set in config file", VaultAddr)
 	}
 	vaultAddr := viper.GetString(VaultAddr)
 
-	if !viper.IsSet(DTVaultPath) {
-		return "", "", fmt.Errorf("key %s is not set in config file", DTVaultPath)
+	if !viper.IsSet(vaultPathKey) {
+		return "", "", fmt.Errorf("key '%s' is not set in config file", vaultPathKey)
 	}
-	vaultPath := viper.GetString(DTVaultPath)
+	vaultPath := viper.GetString(vaultPathKey)
 
 	return vaultAddr, vaultPath, nil
 }
 
-func getAccessToken() (string, error) {
-	vaultAddr, vaultPath, err := getVaultPath()
+// getScopedAccessToken gets an access token using the vault path in the configuration key specified
+// It will request any scopes listed in the scopes string
+func getScopedAccessToken(configKey string, scopes string) (string, error) {
+	vaultAddr, vaultPath, err := getVaultPath(configKey)
 	if err != nil {
 		return "", err
 	}
 
 	err = setupVaultToken(vaultAddr)
 	if err != nil {
-		return "", err
+		return "", nil
 	}
 
-	clientID, clientSecret, err := getSecretFromVault(vaultAddr, vaultPath)
+	clientId, clientSecret, err := getSecretFromVault(vaultAddr, vaultPath)
 	if err != nil {
-		return "", err
+		return "", nil
 	}
 
 	reqData := url.Values{
 		"grant_type":    {"client_credentials"},
-		"scope":         {"storage:logs:read storage:events:read storage:buckets:read"},
-		"client_id":     {clientID},
+		"scope":         {scopes},
+		"client_id":     {clientId},
 		"client_secret": {clientSecret},
 	}.Encode()
 
@@ -144,6 +155,14 @@ func getAccessToken() (string, error) {
 	return token, nil
 }
 
+func getDocumentAccessToken() (string, error) {
+	return getScopedAccessToken(DTDocumentVaultPath, DTDocumentScopes)
+}
+
+func getStorageAccessToken() (string, error) {
+	return getScopedAccessToken(DTStorageVaultPath, DTStorageScopes)
+}
+
 type DTQueryPayload struct {
 	Query            string `json:"query"`
 	MaxResultRecords int    `json:"maxResultRecords"`
@@ -190,6 +209,16 @@ type DTExecuteResults struct {
 	Result []json.RawMessage `json:"records"`
 }
 
+type DTDocumentResult struct {
+	Documents []DTDocument `json:"documents"`
+}
+
+type DTDocument struct {
+	Id   string `json:"id"`
+	Name string `json:"name"`
+	Type string `json:"type"`
+}
+
 func getDTQueryExecution(dtURL string, accessToken string, query string) (reqToken string, error error) {
 	// Note: Currently we are setting a limit of 20,000 lines to pull from Dynatrace
 	// due to a limitation in dynatrace to pull all logs. This limitation can be revoked
@@ -296,6 +325,49 @@ func getDTPollResults(dtURL string, requestToken string, accessToken string) (re
 	}
 }
 
+// getDocumentIDByNameAndType searches using the dynatrace document API using a filter that
+// checks for an exact match of both name and type. It will return the id of the document
+// found, unless it find zero or multiple in which case it will return an error
+func getDocumentIDByNameAndType(dtURL string, accessToken string, docName string, docType string) (string, error) {
+	dtDashFilter := "name == '" + docName + "' and type == '" + docType + "'"
+	parameters := url.Values{
+		"filter": {dtDashFilter},
+	}.Encode()
+
+	requester := Requester{
+		method: http.MethodGet,
+		url:    dtURL + "platform/document/v1/documents?" + parameters,
+		headers: map[string]string{
+			"Content-Type":  "application/json",
+			"Authorization": "Bearer " + accessToken,
+		},
+		successCode: http.StatusOK,
+	}
+
+	result, err := requester.send()
+	if err != nil {
+		return "", fmt.Errorf("could not search for dashboard: %w", err)
+	}
+
+	var dtDocResult DTDocumentResult
+	err = json.Unmarshal([]byte(result), &dtDocResult)
+	if err != nil {
+		return "", fmt.Errorf("response in incorrect format")
+	}
+
+	docCount := len(dtDocResult.Documents)
+	if docCount == 0 {
+		return "", fmt.Errorf("dashboard not found")
+	}
+	if docCount > 1 {
+		return "", fmt.Errorf("dashboard name was ambiguous, %d dashboards found", docCount)
+	}
+
+	dtDashboard := dtDocResult.Documents[0]
+
+	return dtDashboard.Id, nil
+}
+
 func getLogs(dtURL string, accessToken string, requestToken string, dumpWriter io.Writer) error {
 	resp, err := getDTPollResults(dtURL, requestToken, accessToken)
 	if err != nil {
diff --git a/cmd/dynatrace/rootCmd.go b/cmd/dynatrace/rootCmd.go
index baf6b4de7..77b0252d4 100644
--- a/cmd/dynatrace/rootCmd.go
+++ b/cmd/dynatrace/rootCmd.go
@@ -15,6 +15,7 @@ func NewCmdDynatrace() *cobra.Command {
 
 	dtCmd.AddCommand(NewCmdLogs())
 	dtCmd.AddCommand(newCmdURL())
+	dtCmd.AddCommand(newCmdDashboard())
 	dtCmd.AddCommand(NewCmdHCPMustGather())
 
 	return dtCmd


Reviews:
user: login: iamkirkbater
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T01:23:07Z
user: login: smarthall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T01:35:11Z
user: login: devppratik
body: Some naming nit picks to make it consistent
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-07T03:45:40Z
user: login: smarthall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T03:59:06Z
user: login: smarthall
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-07T03:59:09Z

Review comments:
diff_hunk: @@ -0,0 +1,52 @@
+package dynatrace
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	cmdutil "k8s.io/kubectl/pkg/cmd/util"
+)
+
+var (
+	dashboardName string
+	clusterId     string
+)
+
+func newCmdDash() *cobra.Command {
+	urlCmd := &cobra.Command{
+		Use:               "dash --cluster-id CLUSTER_ID",
path: cmd/dynatrace/dashCmd.go
user: login: iamkirkbater
body: Can we use a full name here, `dashboard` and add an alias of `dash`?
created_at: 2025-04-07T01:22:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,52 @@
+package dynatrace
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	cmdutil "k8s.io/kubectl/pkg/cmd/util"
+)
+
+var (
+	dashboardName string
+	clusterId     string
+)
+
+func newCmdDash() *cobra.Command {
+	urlCmd := &cobra.Command{
+		Use:               "dash --cluster-id CLUSTER_ID",
path: cmd/dynatrace/dashCmd.go
user: login: smarthall
body: @iamkirkbater  Sure can! Fixed.
created_at: 2025-04-07T01:35:11Z
author_association: MEMBER
diff_hunk: @@ -15,6 +15,7 @@ func NewCmdDynatrace() *cobra.Command {
 
 	dtCmd.AddCommand(NewCmdLogs())
 	dtCmd.AddCommand(newCmdURL())
+	dtCmd.AddCommand(newCmdDash())
path: cmd/dynatrace/rootCmd.go
user: login: devppratik
body: ```suggestion
	dtCmd.AddCommand(newCmdDashboard())
```

created_at: 2025-04-07T03:44:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+package dynatrace
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	cmdutil "k8s.io/kubectl/pkg/cmd/util"
+)
+
+var (
+	dashboardName string
+	clusterId     string
+)
+
+func newCmdDash() *cobra.Command {
path: cmd/dynatrace/dashCmd.go
user: login: devppratik
body: ```suggestion
func newCmdDashboard() *cobra.Command {
```
created_at: 2025-04-07T03:44:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -15,6 +15,7 @@ func NewCmdDynatrace() *cobra.Command {
 
 	dtCmd.AddCommand(NewCmdLogs())
 	dtCmd.AddCommand(newCmdURL())
+	dtCmd.AddCommand(newCmdDash())
path: cmd/dynatrace/rootCmd.go
user: login: smarthall
body: :+1: Changes made
created_at: 2025-04-07T03:59:06Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,53 @@
+package dynatrace
+
+import (
+	"fmt"
+
+	"github.com/spf13/cobra"
+	cmdutil "k8s.io/kubectl/pkg/cmd/util"
+)
+
+var (
+	dashboardName string
+	clusterId     string
+)
+
+func newCmdDash() *cobra.Command {
path: cmd/dynatrace/dashCmd.go
user: login: smarthall
body: :+1: Changes made
created_at: 2025-04-07T03:59:08Z
author_association: MEMBER

PR comments:
user: login: smarthall
created_at: 2025-04-03T05:13:35Z
body: I'll refactor the argument style to match the new style being implemented in this PR: https://github.com/openshift/osdctl/pull/694 after [this discussion](https://redhat-internal.slack.com/archives/CFJD1NZFT/p1743630166716459)
author_association: MEMBER
user: login: smarthall
created_at: 2025-04-04T00:35:05Z
body: Refactor is done, please review
author_association: MEMBER
user: login: devppratik
created_at: 2025-04-07T08:42:21Z
body: /lgtm
author_association: CONTRIBUTOR
user: login: devppratik
created_at: 2025-04-07T08:43:29Z
body: /approve
author_association: CONTRIBUTOR
user: login: openshift-ci[bot]
created_at: 2025-04-07T08:44:47Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/openshift/osdctl/pull/712#issuecomment-2782483205" title="Approved">devppratik</a>*, *<a href="https://github.com/openshift/osdctl/pull/712#" title="Author self-approved">smarthall</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=openshift%2Fosdctl).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/openshift/osdctl/blob/master/OWNERS)~~ [devppratik]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR
user: login: openshift-ci-robot
created_at: 2025-04-07T10:21:56Z
body: /retest-required

Remaining retests: 0 against base HEAD 8aea93fa0cb276e9d08265ffb46cd59b0c3732aa and 2 for PR HEAD e8abbc27cb8a4298b4338f2bcfcfd88d0656bb01 in total

author_association: NONE
user: login: openshift-ci[bot]
created_at: 2025-04-07T11:38:09Z
body: @smarthall: all tests **passed!**


[Full PR test history](https://prow.ci.openshift.org/pr-history?org=openshift&repo=osdctl&pr=712). [Your PR dashboard](https://prow.ci.openshift.org/pr?query=is:pr+state:open+author:smarthall).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
author_association: CONTRIBUTOR
user: login: joshbranham
created_at: 2025-04-07T14:32:01Z
body: Can we make sure to add some form of tests for any new commands in the future? We need to be better about this for the entire codebase, but anything new should at least have some test of the business logic.
author_association: CONTRIBUTOR