From 1280a9703c77d82b055d8017a85f8c497cb66a56 Mon Sep 17 00:00:00 2001 From: Nicolas Grauss Date: Mon, 13 Apr 2026 10:51:06 +0200 Subject: [PATCH] SREP-347: Adding 'osdctl rhobs' subcommand to interact with RHOBS --- cmd/cmd.go | 2 + cmd/dynatrace/requests.go | 186 +++------------ cmd/requester/requester.go | 119 ++++++++++ cmd/rhobs/logsCmd.go | 38 ++++ cmd/rhobs/metricsCmd.go | 62 +++++ cmd/rhobs/requests.go | 298 +++++++++++++++++++++++++ cmd/rhobs/rootCmd.go | 20 ++ cmd/rhobs/urlCmd.go | 50 +++++ cmd/{dynatrace => vault}/vault.go | 97 +++++--- cmd/{dynatrace => vault}/vault_test.go | 2 +- 10 files changed, 688 insertions(+), 186 deletions(-) create mode 100644 cmd/requester/requester.go create mode 100644 cmd/rhobs/logsCmd.go create mode 100644 cmd/rhobs/metricsCmd.go create mode 100644 cmd/rhobs/requests.go create mode 100644 cmd/rhobs/rootCmd.go create mode 100644 cmd/rhobs/urlCmd.go rename cmd/{dynatrace => vault}/vault.go (52%) rename cmd/{dynatrace => vault}/vault_test.go (99%) diff --git a/cmd/cmd.go b/cmd/cmd.go index af84635ea..310b1235e 100644 --- a/cmd/cmd.go +++ b/cmd/cmd.go @@ -35,6 +35,7 @@ import ( "github.com/openshift/osdctl/cmd/network" "github.com/openshift/osdctl/cmd/org" "github.com/openshift/osdctl/cmd/promote" + "github.com/openshift/osdctl/cmd/rhobs" "github.com/openshift/osdctl/cmd/servicelog" "github.com/openshift/osdctl/cmd/setup" "github.com/openshift/osdctl/cmd/swarm" @@ -108,6 +109,7 @@ func NewCmdRoot(streams genericclioptions.IOStreams) *cobra.Command { rootCmd.AddCommand(swarm.Cmd) rootCmd.AddCommand(iampermissions.NewCmdIamPermissions()) rootCmd.AddCommand(dynatrace.NewCmdDynatrace()) + rootCmd.AddCommand(rhobs.NewCmdRhobs()) // Add cost command to use AWS Cost Manager rootCmd.AddCommand(cost.NewCmdCost(streams, globalOpts)) diff --git a/cmd/dynatrace/requests.go b/cmd/dynatrace/requests.go index 2f1c13473..f57dc3ee6 100644 --- a/cmd/dynatrace/requests.go +++ b/cmd/dynatrace/requests.go @@ -1,166 +1,34 @@ package dynatrace import ( - "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" - "time" - "github.com/spf13/viper" + "github.com/openshift/osdctl/cmd/requester" ) const ( - 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" + DTStorageVaultPathKey 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" + DTDocumentVaultPathKey string = "dt_document_vault_path" + DTDocumentScopes string = "document:documents:read" + DTDashboardType string = "dashboard" ) -type DTRequestError struct { - Records json.RawMessage `json:"error"` -} - -type Requester struct { - method string - url string - data string - headers map[string]string - successCode int -} - -func (rh *Requester) send() (string, error) { - client := http.Client{ - Timeout: time.Second * 600, - } - - var req *http.Request - var err error - if rh.data != "" { - req, err = http.NewRequest(rh.method, rh.url, bytes.NewBuffer([]byte(rh.data))) - } else { - req, err = http.NewRequest(rh.method, rh.url, nil) - } - - if err != nil { - return "", fmt.Errorf("failed to build request %v", err) - } - - for hdr, val := range rh.headers { - req.Header.Set(hdr, val) - } - - resp, err := client.Do(req) - if err != nil { - return "", err - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - if resp.StatusCode != rh.successCode { - var dtError DTRequestError - err = json.Unmarshal([]byte(body), &dtError) - if err != nil { - return "", err - } - - return "", fmt.Errorf("request failed: %v %s", resp.Status, dtError) - } - - return string(body), nil -} - -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) - } - vaultAddr := viper.GetString(VaultAddr) - - if !viper.IsSet(vaultPathKey) { - return "", "", fmt.Errorf("key '%s' is not set in config file", vaultPathKey) - } - vaultPath := viper.GetString(vaultPathKey) - - return vaultAddr, vaultPath, nil -} - -// 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 "", nil - } - - clientId, clientSecret, err := getSecretFromVault(vaultAddr, vaultPath) - if err != nil { - return "", nil - } - - reqData := url.Values{ - "grant_type": {"client_credentials"}, - "scope": {scopes}, - "client_id": {clientId}, - "client_secret": {clientSecret}, - }.Encode() - - requester := Requester{ - method: http.MethodPost, - url: authURL, - data: string(reqData), - headers: map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - }, - successCode: http.StatusOK, - } - - resp, err := requester.send() - if err != nil { - return "", err - } - - var respObj map[string]interface{} - err = json.Unmarshal([]byte(resp), &respObj) - if err != nil { - return "", err - } - - token, ok := respObj["access_token"].(string) - if !ok { - return "", fmt.Errorf("access token not present in response") - } - - fmt.Println("Successfully authenticated with DynaTrace") - - return token, nil -} - func getDocumentAccessToken() (string, error) { - return getScopedAccessToken(DTDocumentVaultPath, DTDocumentScopes) + return requester.GetScopedAccessToken(authURL, DTDocumentVaultPathKey, DTDocumentScopes) } func getStorageAccessToken() (string, error) { - return getScopedAccessToken(DTStorageVaultPath, DTStorageScopes) + return requester.GetScopedAccessToken(authURL, DTStorageVaultPathKey, DTStorageScopes) } type DTQueryPayload struct { @@ -234,20 +102,20 @@ func getDTQueryExecution(dtURL string, accessToken string, query string) (reqTok return "", err } - requester := Requester{ - method: http.MethodPost, - url: dtURL + "platform/storage/query/v1/query:execute", - data: string(payloadJSON), - headers: map[string]string{ + requester := requester.Requester{ + Method: http.MethodPost, + Url: dtURL + "platform/storage/query/v1/query:execute", + Data: string(payloadJSON), + Headers: map[string]string{ "Content-Type": "application/json", "Authorization": "Bearer " + accessToken, }, - successCode: http.StatusAccepted, + SuccessCode: http.StatusAccepted, } var resp string for { - resp, err = requester.send() + resp, err = requester.Send() if err != nil { return "", err } @@ -290,18 +158,18 @@ func getDTPollResults(dtURL string, requestToken string, accessToken string) (re "request-token": {requestToken}, }.Encode() - requester := Requester{ - method: http.MethodGet, - url: dtURL + "platform/storage/query/v1/query:poll?" + reqData, - headers: map[string]string{ + requester := requester.Requester{ + Method: http.MethodGet, + Url: dtURL + "platform/storage/query/v1/query:poll?" + reqData, + Headers: map[string]string{ "Content-Type": "application/json", "Authorization": "Bearer " + accessToken, }, - successCode: http.StatusOK, + SuccessCode: http.StatusOK, } for { - resp, err := requester.send() + resp, err := requester.Send() if err != nil { return "", err } @@ -334,17 +202,17 @@ func getDocumentIDByNameAndType(dtURL string, accessToken string, docName string "filter": {dtDashFilter}, }.Encode() - requester := Requester{ - method: http.MethodGet, - url: dtURL + "platform/document/v1/documents?" + parameters, - headers: map[string]string{ + requester := 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, + SuccessCode: http.StatusOK, } - result, err := requester.send() + result, err := requester.Send() if err != nil { return "", fmt.Errorf("could not search for dashboard: %w", err) } diff --git a/cmd/requester/requester.go b/cmd/requester/requester.go new file mode 100644 index 000000000..fb74e9ae9 --- /dev/null +++ b/cmd/requester/requester.go @@ -0,0 +1,119 @@ +package requester + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/openshift/osdctl/cmd/vault" + + log "github.com/sirupsen/logrus" +) + +type responseError struct { + Records json.RawMessage `json:"error"` +} + +type Requester struct { + Method string + Url string + Data string + Headers map[string]string + SuccessCode int +} + +func (rh *Requester) Send() (string, error) { + client := http.Client{ + Timeout: time.Second * 600, + } + + var req *http.Request + var err error + if rh.Data != "" { + req, err = http.NewRequest(rh.Method, rh.Url, bytes.NewBuffer([]byte(rh.Data))) + } else { + req, err = http.NewRequest(rh.Method, rh.Url, nil) + } + + if err != nil { + return "", fmt.Errorf("failed to build request %v", err) + } + + for hdr, val := range rh.Headers { + req.Header.Set(hdr, val) + } + + resp, err := client.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != rh.SuccessCode { + var respErr responseError + err = json.Unmarshal([]byte(body), &respErr) + if err != nil { + return "", err + } + + return "", fmt.Errorf("request failed: %v %s", resp.Status, respErr) + } + + return string(body), nil +} + +// 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(authUrl, vaultConfigKey string, scopes string) (string, error) { + clientId, clientSecret, err := vault.GetCredsFromVault(vaultConfigKey) + if err != nil { + return "", err + } + + reqData := url.Values{ + "grant_type": {"client_credentials"}, + "scope": {scopes}, + "client_id": {clientId}, + "client_secret": {clientSecret}, + }.Encode() + + requester := Requester{ + Method: http.MethodPost, + Url: authUrl, + Data: string(reqData), + Headers: map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + }, + SuccessCode: http.StatusOK, + } + + resp, err := requester.Send() + if err != nil { + return "", err + } + + var respObj map[string]interface{} + err = json.Unmarshal([]byte(resp), &respObj) + if err != nil { + return "", err + } + + token, ok := respObj["access_token"].(string) + if !ok { + return "", fmt.Errorf("access token not present in response") + } + + log.Infoln("Successfully authenticated") + + return token, nil +} diff --git a/cmd/rhobs/logsCmd.go b/cmd/rhobs/logsCmd.go new file mode 100644 index 000000000..d37bd3ec7 --- /dev/null +++ b/cmd/rhobs/logsCmd.go @@ -0,0 +1,38 @@ +package rhobs + +import ( + "github.com/openshift/osdctl/pkg/k8s" + "github.com/spf13/cobra" + cmdutil "k8s.io/kubectl/pkg/cmd/util" +) + +var ( + clusterId string +) + +func newCmdLogs() *cobra.Command { + cmd := &cobra.Command{ + Use: "logs --cluster-id ", + Short: "Fetch logs from RHOBS.next", + Run: func(cmd *cobra.Command, args []string) { + var err error + if clusterId == "" { + clusterId, err = k8s.GetCurrentCluster() + if err != nil { + cmdutil.CheckErr(err) + } + } + + err = main_(clusterId) + if err != nil { + cmdutil.CheckErr(err) + } + }, + } + + return cmd +} + +func main_(clusterId string) error { + return nil +} diff --git a/cmd/rhobs/metricsCmd.go b/cmd/rhobs/metricsCmd.go new file mode 100644 index 000000000..53cf05c3e --- /dev/null +++ b/cmd/rhobs/metricsCmd.go @@ -0,0 +1,62 @@ +package rhobs + +import ( + "fmt" + + "github.com/openshift/osdctl/pkg/k8s" + "github.com/spf13/cobra" +) + +var cmdMetricsOptions = struct { + clusterId string + outputFormat string +}{} + +func newCmdMetrics() *cobra.Command { + cmd := &cobra.Command{ + Use: "metrics --cluster-id prometheus-expression", + Short: "Fetch metrics from RHOBS.next", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceErrors = true + + if cmdMetricsOptions.clusterId == "" { + var err error + + cmdMetricsOptions.clusterId, err = k8s.GetCurrentCluster() + if err != nil { + return fmt.Errorf("failed to retrieve ID for current cluster: %v", err) + } + } + + if len(args) != 1 { + return fmt.Errorf("exactly one Prometheus expression must be provided as an argument") + } + + var outputFormat MetricsFormat + switch cmdMetricsOptions.outputFormat { + case string(MetricsFormatTable): + outputFormat = MetricsFormatTable + case string(MetricsFormatCsv): + outputFormat = MetricsFormatCsv + case string(MetricsFormatJson): + outputFormat = MetricsFormatJson + default: + return fmt.Errorf("invalid output format: %s", cmdMetricsOptions.outputFormat) + } + + cmd.SilenceUsage = true + + err := printMetrics(cmdMetricsOptions.clusterId, args[0], outputFormat) + if err != nil { + return fmt.Errorf("failed to retrieve metrics: %v", err) + } + + return nil + }, + } + + cmd.Flags().StringVarP(&cmdMetricsOptions.clusterId, "cluster-id", "C", "", "Name or Internal ID of the cluster (defaults to current cluster context)") + cmd.Flags().StringVarP(&cmdMetricsOptions.outputFormat, "output", "o", string(MetricsFormatTable), "Format of the output (table, csv, json)") + + return cmd +} diff --git a/cmd/rhobs/requests.go b/cmd/rhobs/requests.go new file mode 100644 index 000000000..3e8a92d87 --- /dev/null +++ b/cmd/rhobs/requests.go @@ -0,0 +1,298 @@ +package rhobs + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "sort" + "strings" + + "github.com/openshift/osdctl/cmd/requester" + "github.com/openshift/osdctl/pkg/k8s" + ocmutils "github.com/openshift/osdctl/pkg/utils" + + hivev1 "github.com/openshift/hive/apis/hive/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +const ( + authUrl = "https://sso.redhat.com/auth/realms/redhat-external/protocol/openid-connect/token" + rhobsVaultPathKeyTemplate = "rhobs_%s_vault_path" + clusterIdCdLabel = "api.openshift.com/id" + rhobsCellCdLabel = "ext-hypershift.openshift.io/rhobs-cell" +) + +type MetricsFormat string + +const ( + MetricsFormatTable MetricsFormat = "table" + MetricsFormatCsv MetricsFormat = "csv" + MetricsFormatJson MetricsFormat = "json" +) + +func GetRhobsCell(clusterKey string) (string, error) { + connection, err := ocmutils.CreateConnection() + if err != nil { + return "", err + } + defer connection.Close() + + cluster, err := ocmutils.GetCluster(connection, clusterKey) + if err != nil { + return "", err + } + + if cluster.Hypershift().Enabled() { + cluster, err = ocmutils.GetManagementCluster(cluster.ID()) + if err != nil { + return "", fmt.Errorf("failed to retrieve management cluster for cluster '%s': %v", cluster.ID(), err) + } + } else { + isMC, err := ocmutils.IsManagementCluster(cluster.ID()) + if err != nil { + return "", fmt.Errorf("failed to determine if cluster '%s' is a management cluster: %v", cluster.ID(), err) + } + if !isMC { + return "", fmt.Errorf("cluster '%s' is not a HCP or MC, cannot determine RHOBS cell", cluster.ID()) + } + } + + hiveCluster, err := ocmutils.GetHiveCluster(cluster.ID()) + if err != nil { + return "", fmt.Errorf("failed to retrieve hive cluster for cluster '%s': %v", cluster.ID(), err) + } + + hiveClient, err := k8s.NewWithConn(hiveCluster.ID(), client.Options{}, connection) + if err != nil { + return "", fmt.Errorf("failed to create kube client for hive cluster '%s': %v", hiveCluster.ID(), err) + } + + clusterSelector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{MatchLabels: map[string]string{ + clusterIdCdLabel: cluster.ID(), + }}) + if err != nil { + return "", fmt.Errorf("failed to create label selector for cluster '%s': %v", cluster.ID(), err) + } + + var clusterDeployments hivev1.ClusterDeploymentList + + hiveClient.List(context.TODO(), &clusterDeployments, &client.ListOptions{LabelSelector: clusterSelector}) + if err != nil { + return "", fmt.Errorf("failed to list cluster deployments for cluster '%s': %v", cluster.ID(), err) + } + + if len(clusterDeployments.Items) != 1 { + return "", fmt.Errorf("expected to find exactly 1 cluster deployment for cluster '%s', but found %d", cluster.ID(), len(clusterDeployments.Items)) + } + + clusterDeployment := clusterDeployments.Items[0] + rhobsCell, ok := clusterDeployment.Labels[rhobsCellCdLabel] + + if !ok { + return "", fmt.Errorf("cluster deployment for cluster '%s' does not have the expected label", cluster.ID()) + } + + return rhobsCell, nil +} + +func getAccessToken(clusterKey string) (string, error) { + connection, err := ocmutils.CreateConnection() + if err != nil { + return "", err + } + defer connection.Close() + + envName := ocmutils.GetCurrentOCMEnv(connection) + + return requester.GetScopedAccessToken(authUrl, fmt.Sprintf(rhobsVaultPathKeyTemplate, envName), "profile") +} + +type rhobsMetricsResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []rhobsMetric `json:"result"` + } `json:"data"` +} + +type rhobsMetric struct { + Metric map[string]string `json:"metric"` + Value []interface{} `json:"value"` +} + +func printMetrics(clusterKey, promExpr string, format MetricsFormat) error { + accessToken, err := getAccessToken(clusterKey) + if err != nil { + return fmt.Errorf("failed to get access token: %v", err) + } + + rhobsCell, err := GetRhobsCell(clusterKey) + if err != nil { + return fmt.Errorf("failed to get RHOBS cell: %v", err) + } + + queryData := url.Values{ + "query": {promExpr}, + }.Encode() + + requester := requester.Requester{ + Method: http.MethodGet, + Url: "https://" + rhobsCell + "/api/metrics/v1/hcp/api/v1/query?" + queryData, + Headers: map[string]string{ + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": "Bearer " + accessToken, + }, + SuccessCode: http.StatusOK, + } + + resp, err := requester.Send() + if err != nil { + return fmt.Errorf("failed to send request to RHOBS: %v", err) + } + + var metricsResp rhobsMetricsResponse + err = json.Unmarshal([]byte(resp), &metricsResp) + if err != nil { + return fmt.Errorf("failed to unmarshal response from RHOBS: %v", err) + } + + if metricsResp.Status != "success" { + return fmt.Errorf("RHOBS query failed with status: %s", metricsResp.Status) + } + + switch format { + case MetricsFormatTable: + printMetricsAsTable(metricsResp.Data.Result) + case MetricsFormatCsv: + printMetricsAsCsv(metricsResp.Data.Result) + case MetricsFormatJson: + return printMetricsAsJson(metricsResp.Data.Result) + default: + return fmt.Errorf("unsupported output format: %s", format) + } + + return nil +} + +type tableColumn struct { + name string + width int +} + +func getTableColumns(metrics []rhobsMetric) []tableColumn { + timeColumn := tableColumn{name: "TIME", width: len("TIME")} + valueColumn := tableColumn{name: "VALUE", width: len("VALUE")} + labelNameToColumn := make(map[string]*tableColumn) + labelNames := []string{} // to maintain order of label columns + + for _, metric := range metrics { + if len(metric.Value) < 2 { + continue + } + time := fmt.Sprintf("%.3f", metric.Value[0]) + if len(time) > timeColumn.width { + timeColumn.width = len(time) + } + value := fmt.Sprintf("%s", metric.Value[1]) + if len(value) > valueColumn.width { + valueColumn.width = len(value) + } + + for labelName, labelValue := range metric.Metric { + if _, exists := labelNameToColumn[labelName]; !exists { + labelNameToColumn[labelName] = &tableColumn{name: labelName, width: len(labelName)} + labelNames = append(labelNames, labelName) + } + if len(labelValue) > labelNameToColumn[labelName].width { + labelNameToColumn[labelName].width = len(labelValue) + } + } + } + + columns := []tableColumn{timeColumn, valueColumn} + + sort.Strings(labelNames) + for _, labelName := range labelNames { + columns = append(columns, *labelNameToColumn[labelName]) + } + + return columns +} + +func printMetricsAsTable(metrics []rhobsMetric) { + columns := getTableColumns(metrics) + separatorLine := "+" + for _, column := range columns { + separatorLine += strings.Repeat("-", column.width+2) + "+" + } + + fmt.Println(separatorLine) + + // Header + fmt.Print("|") + for _, column := range columns { + fmt.Print(fmt.Sprintf(" %-*s |", column.width, column.name)) + } + fmt.Println() + fmt.Println(separatorLine) + + // Rows + + for _, metric := range metrics { + if len(metric.Value) < 2 { + continue + } + time := fmt.Sprintf("%.3f", metric.Value[0]) + value := fmt.Sprintf("%s", metric.Value[1]) + fmt.Print(fmt.Sprintf("| %*s | %*s |", columns[0].width, time, columns[1].width, value)) + + for _, column := range columns[2:] { + labelValue := metric.Metric[column.name] + fmt.Print(fmt.Sprintf(" %*s |", column.width, labelValue)) + } + fmt.Println() + } + fmt.Println(separatorLine) +} + +func printMetricsAsCsv(metrics []rhobsMetric) { + columns := getTableColumns(metrics) + + writer := csv.NewWriter(os.Stdout) + + header := []string{} + for _, column := range columns { + header = append(header, column.name) + } + writer.Write(header) + + for _, metric := range metrics { + if len(metric.Value) < 2 { + continue + } + row := []string{fmt.Sprintf("%.3f", metric.Value[0]), fmt.Sprintf("%s", metric.Value[1])} + for _, column := range columns[2:] { + row = append(row, metric.Metric[column.name]) + } + writer.Write(row) + } + + writer.Flush() +} + +func printMetricsAsJson(metrics []rhobsMetric) error { + metricsBytes, err := json.MarshalIndent(metrics, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal metrics data: %v", err) + } + + fmt.Println(string(metricsBytes)) + + return nil +} diff --git a/cmd/rhobs/rootCmd.go b/cmd/rhobs/rootCmd.go new file mode 100644 index 000000000..1ff98a5eb --- /dev/null +++ b/cmd/rhobs/rootCmd.go @@ -0,0 +1,20 @@ +package rhobs + +import ( + "github.com/spf13/cobra" +) + +func NewCmdRhobs() *cobra.Command { + cmd := &cobra.Command{ + Use: "rhobs", + Short: "RHOBS.next related utilities", + Args: cobra.NoArgs, + DisableAutoGenTag: true, + } + + cmd.AddCommand(newCmdCell()) + cmd.AddCommand(newCmdLogs()) + cmd.AddCommand(newCmdMetrics()) + + return cmd +} diff --git a/cmd/rhobs/urlCmd.go b/cmd/rhobs/urlCmd.go new file mode 100644 index 000000000..e3bbc5635 --- /dev/null +++ b/cmd/rhobs/urlCmd.go @@ -0,0 +1,50 @@ +package rhobs + +import ( + "fmt" + + "github.com/openshift/osdctl/pkg/k8s" + "github.com/spf13/cobra" +) + +var cmdCellOptions = struct { + clusterId string +}{} + +func newCmdCell() *cobra.Command { + cmd := &cobra.Command{ + Use: "cell --cluster-id ", + Short: "Get the RHOBS cell for a given MC or HCP cluster", + RunE: func(cmd *cobra.Command, args []string) error { + cmd.SilenceErrors = true + + if cmdCellOptions.clusterId == "" { + var err error + + cmdCellOptions.clusterId, err = k8s.GetCurrentCluster() + if err != nil { + return fmt.Errorf("failed to retrieve ID for current cluster: %v", err) + } + } + + cmd.SilenceUsage = true + + return retrieveCell() + }, + } + + cmd.Flags().StringVarP(&cmdCellOptions.clusterId, "cluster-id", "C", "", "Name or Internal ID of the cluster (defaults to current cluster context)") + + return cmd +} + +func retrieveCell() error { + rhobsCell, err := GetRhobsCell(cmdCellOptions.clusterId) + if err != nil { + return fmt.Errorf("failed to retrieve RHOBS cell: %v", err) + } + + fmt.Println("RHOBS cell -", rhobsCell) + + return nil +} diff --git a/cmd/dynatrace/vault.go b/cmd/vault/vault.go similarity index 52% rename from cmd/dynatrace/vault.go rename to cmd/vault/vault.go index 415d5c4cf..5b865e3a1 100644 --- a/cmd/dynatrace/vault.go +++ b/cmd/vault/vault.go @@ -1,4 +1,4 @@ -package dynatrace +package vault import ( "encoding/json" @@ -7,12 +7,32 @@ import ( "os/exec" ocmutils "github.com/openshift/ocm-container/pkg/utils" + + log "github.com/sirupsen/logrus" + "github.com/spf13/viper" ) -type response struct { - Data struct { - Data map[string]interface{} `json:"data"` - } `json:"data"` +const ( + VaultAddrKey string = "vault_address" +) + +type VaultRef struct { + Addr string + Path string +} + +func GetVaultRef(vaultPathKey string) (VaultRef, error) { + if !viper.IsSet(VaultAddrKey) { + return VaultRef{}, fmt.Errorf("key '%s' is not set in config file", VaultAddrKey) + } + vaultAddr := viper.GetString(VaultAddrKey) + + if !viper.IsSet(vaultPathKey) { + return VaultRef{}, fmt.Errorf("key '%s' is not set in config file", vaultPathKey) + } + vaultPath := viper.GetString(vaultPathKey) + + return VaultRef{Addr: vaultAddr, Path: vaultPath}, nil } // setupVaultToken ensures a valid Vault token exists by checking the current @@ -26,7 +46,7 @@ func setupVaultToken(vaultAddr string) error { versionCheckCmd := exec.Command("vault", "version") - versionCheckCmd.Stdout = os.Stdout + versionCheckCmd.Stdout = os.Stderr versionCheckCmd.Stderr = os.Stderr if err = versionCheckCmd.Run(); err != nil { @@ -38,16 +58,16 @@ func setupVaultToken(vaultAddr string) error { tokenCheckCmd.Stderr = nil // get new token since old token has expired if err = tokenCheckCmd.Run(); err != nil { - fmt.Println("Vault token no longer valid, requesting new token") + log.Infoln("Vault token no longer valid, requesting new token") // Check if we're in a container environment (OCM_CONTAINER env var is set) // If so, skip automatic browser launch and print the URL for manual authentication loginArgs := []string{"login", "-method=oidc", "-no-print"} if ocmutils.IsRunningInOcmContainer() { - fmt.Println("\nNOTE: Running in container mode - OIDC authentication requires port forwarding.") - fmt.Println("Ensure port 8250 is exposed in your ocm-container configuration:") - fmt.Println(" Add 'launch-opts: \"-p 8250:8250\"' to ~/.config/ocm-container/ocm-container.yaml") - fmt.Println("Then restart your ocm-container for the change to take effect.") + log.Infoln("\nNOTE: Running in container mode - OIDC authentication requires port forwarding.") + log.Infoln("Ensure port 8250 is exposed in your ocm-container configuration:") + log.Infoln(" Add 'launch-opts: \"-p 8250:8250\"' to ~/.config/ocm-container/ocm-container.yaml") + log.Infoln("Then restart your ocm-container for the change to take effect.") // In container: skip browser launch and listen on all interfaces (0.0.0.0) // so the callback can be reached from the host browser via localhost:8250 @@ -57,7 +77,7 @@ func setupVaultToken(vaultAddr string) error { // Show output when using skip_browser so user can see the authentication URL if ocmutils.IsRunningInOcmContainer() { - loginCmd.Stdout = os.Stdout + loginCmd.Stdout = os.Stderr loginCmd.Stderr = os.Stderr } else { loginCmd.Stdout = nil @@ -78,36 +98,61 @@ func setupVaultToken(vaultAddr string) error { return fmt.Errorf("error running 'vault login': %v", err) } - fmt.Println("Acquired vault token") + log.Infoln("Acquired vault token") } return nil } -func getSecretFromVault(vaultAddr, vaultPath string) (id string, secret string, error error) { - err := os.Setenv("VAULT_ADDR", vaultAddr) +type vaultOutput struct { + Data struct { + Data map[string]string `json:"data"` + } `json:"data"` +} + +func GetSecretFromVault(vaultRef VaultRef) (*map[string]string, error) { + err := setupVaultToken(vaultRef.Addr) if err != nil { - return "", "", fmt.Errorf("error setting environment variable: %v", err) + return nil, err } - kvGetCommand := exec.Command("vault", "kv", "get", "-format=json", vaultPath) + err = os.Setenv("VAULT_ADDR", vaultRef.Addr) + if err != nil { + return nil, fmt.Errorf("error setting environment variable: %v", err) + } + + kvGetCommand := exec.Command("vault", "kv", "get", "-format=json", vaultRef.Path) output, err := kvGetCommand.Output() if err != nil { - fmt.Println("Error running 'vault kv get':", err) - return "", "", nil + return nil, fmt.Errorf("error running 'vault kv get %s' (VAULT_ADDR: %s): %v", vaultRef.Path, vaultRef.Addr, err) + } + + var formattedOutput vaultOutput + if err := json.Unmarshal(output, &formattedOutput); err != nil { + return nil, fmt.Errorf("error unmarshaling JSON output: %v", err) + } + + return &formattedOutput.Data.Data, nil +} + +func GetCredsFromVault(configKey string) (string, string, error) { + vaultRef, err := GetVaultRef(configKey) + if err != nil { + return "", "", err } - var resp response - if err := json.Unmarshal(output, &resp); err != nil { - return "", "", fmt.Errorf("error unmarshaling JSON response: %v", err) + secretData, err := GetSecretFromVault(vaultRef) + if err != nil { + return "", "", err } - clientID, ok := resp.Data.Data["client_id"].(string) + + clientID, ok := (*secretData)["client_id"] if !ok { - return "", "", fmt.Errorf("error extracting secret data from JSON response") + return "", "", fmt.Errorf("no 'client_id' in %s vault secret (VAULT_ADDR: %s)", vaultRef.Path, vaultRef.Addr) } - clientSecret, ok := resp.Data.Data["client_secret"].(string) + clientSecret, ok := (*secretData)["client_secret"] if !ok { - return "", "", fmt.Errorf("error extracting secret data from JSON response") + return "", "", fmt.Errorf("no 'client_secret' in %s vault secret (VAULT_ADDR: %s)", vaultRef.Path, vaultRef.Addr) } return clientID, clientSecret, nil diff --git a/cmd/dynatrace/vault_test.go b/cmd/vault/vault_test.go similarity index 99% rename from cmd/dynatrace/vault_test.go rename to cmd/vault/vault_test.go index 2a64e61a6..c91a19f50 100644 --- a/cmd/dynatrace/vault_test.go +++ b/cmd/vault/vault_test.go @@ -1,4 +1,4 @@ -package dynatrace +package vault import ( "os"