From e262e30794885610c6e70b607a18d18fb98af8be Mon Sep 17 00:00:00 2001 From: Pierre Gerbelot Date: Wed, 24 Jun 2026 17:24:23 +0200 Subject: [PATCH] feat(cluster): add cluster analysis commands --- cmd/cluster_analysis.go | 118 ++++++++++++++++++++ cmd/cluster_analysis_cost_recommendation.go | 53 +++++++++ cmd/cluster_analysis_deprecated_api.go | 49 ++++++++ cmd/cluster_analysis_list.go | 88 +++++++++++++++ cmd/cluster_analysis_logs.go | 67 +++++++++++ cmd/cluster_analysis_runner.go | 86 ++++++++++++++ go.mod | 2 +- go.sum | 6 +- 8 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 cmd/cluster_analysis.go create mode 100644 cmd/cluster_analysis_cost_recommendation.go create mode 100644 cmd/cluster_analysis_deprecated_api.go create mode 100644 cmd/cluster_analysis_list.go create mode 100644 cmd/cluster_analysis_logs.go create mode 100644 cmd/cluster_analysis_runner.go diff --git a/cmd/cluster_analysis.go b/cmd/cluster_analysis.go new file mode 100644 index 00000000..bd942c69 --- /dev/null +++ b/cmd/cluster_analysis.go @@ -0,0 +1,118 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/qovery/qovery-client-go" + + "github.com/qovery/qovery-cli/utils" +) + +var ( + clusterAnalysisClusterId string + clusterAnalysisId string + clusterAnalysisOutputFormat string + clusterAnalysisPrometheusUrl string + clusterAnalysisCmdArgs []string + clusterAnalysisTargetK8sVersion string + clusterAnalysisWatch bool + clusterAnalysisNoLogs bool + clusterAnalysisJson bool +) + +var clusterAnalysisCmd = &cobra.Command{ + Use: "analysis", + Short: "Run and inspect read-only cluster analyses (e.g. cost recommendations)", + Run: func(cmd *cobra.Command, args []string) { + utils.Capture(cmd) + if len(args) == 0 { + _ = cmd.Help() + os.Exit(0) + } + }, +} + +func init() { + clusterCmd.AddCommand(clusterAnalysisCmd) +} + +// parseAnalysisOutput maps a CLI --output value to the engine output format. +func parseAnalysisOutput(s string) (qovery.ClusterAnalysisOutputFormat, error) { + switch strings.ToLower(strings.TrimSpace(s)) { + case "", "json": + return qovery.CLUSTERANALYSISOUTPUTFORMAT_JSON, nil + case "table": + return qovery.CLUSTERANALYSISOUTPUTFORMAT_TABLE, nil + case "csv": + return qovery.CLUSTERANALYSISOUTPUTFORMAT_CSV, nil + default: + return "", fmt.Errorf("invalid output format %q (allowed: table, json, csv)", s) + } +} + +// isFinalAnalysisStatus reports whether the analysis reached a terminal state. +func isFinalAnalysisStatus(status qovery.ClusterAnalysisStatus) bool { + switch status { + case qovery.CLUSTERANALYSISSTATUS_SUCCEEDED, + qovery.CLUSTERANALYSISSTATUS_FAILED, + qovery.CLUSTERANALYSISSTATUS_TERMINATED: + return true + default: + return false + } +} + +// httpError formats an API error using the response body when available. +func httpError(res *http.Response, err error) error { + if res == nil { + return err + } + + if res.Body == nil { + if err != nil { + return fmt.Errorf("status code: %s: %w", res.Status, err) + } + return fmt.Errorf("status code: %s", res.Status) + } + + defer func() { _ = res.Body.Close() }() + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + if err != nil { + return fmt.Errorf("status code: %s ; cannot read response body: %w ; original error: %w", res.Status, readErr, err) + } + return fmt.Errorf("status code: %s ; cannot read response body: %w", res.Status, readErr) + } + + if err != nil { + return fmt.Errorf("status code: %s ; body: %s ; original error: %w", res.Status, string(body), err) + } + return fmt.Errorf("status code: %s ; body: %s", res.Status, string(body)) +} + +// printAnalysisLogs fetches and prints the persisted report/log lines of an analysis. +func printAnalysisLogs(client *qovery.APIClient, clusterId string, analysisId string) error { + logs, res, err := client.ClustersAPI.ListClusterAnalysisLogs(context.Background(), clusterId, analysisId).Execute() + if err != nil { + return httpError(res, err) + } + + utils.Println(analysisReportFromLogs(logs.GetResults())) + + return nil +} + +func analysisReportFromLogs(logs []qovery.ClusterAnalysisLogResponse) string { + lines := make([]string, 0, len(logs)) + for _, line := range logs { + lines = append(lines, line.GetMessage()) + } + return strings.Join(lines, "\n") +} diff --git a/cmd/cluster_analysis_cost_recommendation.go b/cmd/cluster_analysis_cost_recommendation.go new file mode 100644 index 00000000..414ec8ad --- /dev/null +++ b/cmd/cluster_analysis_cost_recommendation.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/qovery/qovery-client-go" + + "github.com/qovery/qovery-cli/utils" +) + +var clusterAnalysisCostRecommendationCmd = &cobra.Command{ + Use: "cost-recommendation", + Short: "Start a cluster cost recommendation analysis, optionally wait for completion, then print its report", + Run: func(cmd *cobra.Command, args []string) { + utils.Capture(cmd) + + request, err := newCostRecommendationRequest() + if err != nil { + utils.PrintlnError(err) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + + runClusterAnalysis(request) + }, +} + +func newCostRecommendationRequest() (*qovery.ClusterAnalysisRequest, error) { + outputFormat, err := parseAnalysisOutput(clusterAnalysisOutputFormat) + if err != nil { + return nil, err + } + + request := qovery.NewClusterAnalysisRequest(qovery.CLUSTERANALYSISKIND_COST_RECOMMENDATION, outputFormat) + if clusterAnalysisPrometheusUrl != "" { + request.SetPrometheusUrl(clusterAnalysisPrometheusUrl) + } + if len(clusterAnalysisCmdArgs) > 0 { + request.SetCmdArgs(clusterAnalysisCmdArgs) + } + + return request, nil +} + +func init() { + clusterAnalysisCmd.AddCommand(clusterAnalysisCostRecommendationCmd) + + addClusterAnalysisRunFlags(clusterAnalysisCostRecommendationCmd) + clusterAnalysisCostRecommendationCmd.Flags().StringVar(&clusterAnalysisPrometheusUrl, "prometheus-url", "", "Optional Prometheus URL") + clusterAnalysisCostRecommendationCmd.Flags().StringArrayVar(&clusterAnalysisCmdArgs, "cmd-arg", nil, "Optional allowlisted command argument. Repeat for each argument, e.g. --cmd-arg=--history_duration --cmd-arg=336") +} diff --git a/cmd/cluster_analysis_deprecated_api.go b/cmd/cluster_analysis_deprecated_api.go new file mode 100644 index 00000000..af0d4011 --- /dev/null +++ b/cmd/cluster_analysis_deprecated_api.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/qovery/qovery-client-go" + + "github.com/qovery/qovery-cli/utils" +) + +var clusterAnalysisDeprecatedApiCmd = &cobra.Command{ + Use: "deprecated-api", + Short: "Start a deprecated Kubernetes API analysis, optionally wait for completion, then print its report", + Run: func(cmd *cobra.Command, args []string) { + utils.Capture(cmd) + + request, err := newDeprecatedApiRequest() + if err != nil { + utils.PrintlnError(err) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + + runClusterAnalysis(request) + }, +} + +func newDeprecatedApiRequest() (*qovery.ClusterAnalysisRequest, error) { + outputFormat, err := parseAnalysisOutput(clusterAnalysisOutputFormat) + if err != nil { + return nil, err + } + + request := qovery.NewClusterAnalysisRequest(qovery.CLUSTERANALYSISKIND_DEPRECATED_API_CHECK, outputFormat) + if clusterAnalysisTargetK8sVersion != "" { + request.SetTargetKubernetesVersion(clusterAnalysisTargetK8sVersion) + } + + return request, nil +} + +func init() { + clusterAnalysisCmd.AddCommand(clusterAnalysisDeprecatedApiCmd) + + addClusterAnalysisRunFlags(clusterAnalysisDeprecatedApiCmd) + clusterAnalysisDeprecatedApiCmd.Flags().StringVar(&clusterAnalysisTargetK8sVersion, "target-kubernetes-version", "", "Optional target Kubernetes version") +} diff --git a/cmd/cluster_analysis_list.go b/cmd/cluster_analysis_list.go new file mode 100644 index 00000000..b762c991 --- /dev/null +++ b/cmd/cluster_analysis_list.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/qovery/qovery-client-go" + + "github.com/qovery/qovery-cli/utils" +) + +var clusterAnalysisListCmd = &cobra.Command{ + Use: "list", + Short: "List previous analyses for a cluster", + Run: func(cmd *cobra.Command, args []string) { + utils.Capture(cmd) + + client := utils.GetQoveryClientPanicInCaseOfError() + + analyses, res, err := client.ClustersAPI.ListClusterAnalyses(context.Background(), clusterAnalysisClusterId).Execute() + if err != nil { + utils.PrintlnError(httpError(res, err)) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + + if clusterAnalysisJson { + utils.Println(getAnalysisJsonOutput(analyses.GetResults())) + return + } + + var data [][]string + for _, a := range analyses.GetResults() { + data = append(data, []string{ + a.GetId(), + string(a.GetKind()), + string(a.GetStatus()), + a.GetCreatedAt().Format(time.RFC3339), + a.GetTriggeredBy(), + a.GetErrorMessage(), + }) + } + + err = utils.PrintTable([]string{"Id", "Kind", "Status", "Created At", "Triggered By", "Error"}, data) + if err != nil { + utils.PrintlnError(err) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + }, +} + +func getAnalysisJsonOutput(analyses []qovery.ClusterAnalysisResponse) string { + var results []interface{} + for _, a := range analyses { + createdAt := a.GetCreatedAt() + updatedAt := a.GetUpdatedAt() + results = append(results, map[string]interface{}{ + "id": a.GetId(), + "cluster_id": a.GetClusterId(), + "kind": a.GetKind(), + "status": a.GetStatus(), + "created_at": utils.ToIso8601(&createdAt), + "updated_at": utils.ToIso8601(&updatedAt), + "triggered_by": a.GetTriggeredBy(), + "error": a.GetErrorMessage(), + }) + } + + j, err := json.Marshal(results) + if err != nil { + utils.PrintlnError(err) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + return string(j) +} + +func init() { + clusterAnalysisCmd.AddCommand(clusterAnalysisListCmd) + clusterAnalysisListCmd.Flags().StringVarP(&clusterAnalysisClusterId, "cluster-id", "c", "", "Cluster ID") + clusterAnalysisListCmd.Flags().BoolVar(&clusterAnalysisJson, "json", false, "JSON output") + _ = clusterAnalysisListCmd.MarkFlagRequired("cluster-id") +} diff --git a/cmd/cluster_analysis_logs.go b/cmd/cluster_analysis_logs.go new file mode 100644 index 00000000..903cfbb5 --- /dev/null +++ b/cmd/cluster_analysis_logs.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "context" + "encoding/json" + "os" + + "github.com/spf13/cobra" + + "github.com/qovery/qovery-client-go" + + "github.com/qovery/qovery-cli/utils" +) + +var clusterAnalysisLogsCmd = &cobra.Command{ + Use: "logs", + Short: "Print the report/logs of a past cluster analysis", + Run: func(cmd *cobra.Command, args []string) { + utils.Capture(cmd) + + client := utils.GetQoveryClientPanicInCaseOfError() + + logs, res, err := client.ClustersAPI.ListClusterAnalysisLogs(context.Background(), clusterAnalysisClusterId, clusterAnalysisId).Execute() + if err != nil { + utils.PrintlnError(httpError(res, err)) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + + if clusterAnalysisJson { + utils.Println(getAnalysisLogsJsonOutput(logs.GetResults())) + return + } + + utils.Println(analysisReportFromLogs(logs.GetResults())) + }, +} + +func getAnalysisLogsJsonOutput(logs []qovery.ClusterAnalysisLogResponse) string { + var results []interface{} + for _, line := range logs { + timestamp := line.GetTimestamp() + results = append(results, map[string]interface{}{ + "timestamp": utils.ToIso8601(×tamp), + "level": line.GetLevel(), + "message": line.GetMessage(), + "line_order": line.GetLineOrder(), + }) + } + + j, err := json.Marshal(results) + if err != nil { + utils.PrintlnError(err) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + return string(j) +} + +func init() { + clusterAnalysisCmd.AddCommand(clusterAnalysisLogsCmd) + clusterAnalysisLogsCmd.Flags().StringVarP(&clusterAnalysisClusterId, "cluster-id", "c", "", "Cluster ID") + clusterAnalysisLogsCmd.Flags().StringVarP(&clusterAnalysisId, "analysis-id", "a", "", "Analysis ID") + clusterAnalysisLogsCmd.Flags().BoolVar(&clusterAnalysisJson, "json", false, "JSON output") + _ = clusterAnalysisLogsCmd.MarkFlagRequired("cluster-id") + _ = clusterAnalysisLogsCmd.MarkFlagRequired("analysis-id") +} diff --git a/cmd/cluster_analysis_runner.go b/cmd/cluster_analysis_runner.go new file mode 100644 index 00000000..029aff8c --- /dev/null +++ b/cmd/cluster_analysis_runner.go @@ -0,0 +1,86 @@ +package cmd + +import ( + "context" + "os" + "time" + + "github.com/pterm/pterm" + "github.com/spf13/cobra" + + "github.com/qovery/qovery-client-go" + + "github.com/qovery/qovery-cli/utils" +) + +func runClusterAnalysis(request *qovery.ClusterAnalysisRequest) { + client := utils.GetQoveryClientPanicInCaseOfError() + ctx := context.Background() + + analysis, res, err := client.ClustersAPI. + StartClusterAnalysis(ctx, clusterAnalysisClusterId). + ClusterAnalysisRequest(*request). + Execute() + if err != nil { + utils.PrintlnError(httpError(res, err)) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + + analysisId := analysis.GetId() + utils.Println("Analysis " + pterm.FgBlue.Sprintf("%s", analysisId) + " started (" + string(analysis.GetStatus()) + ")") + + if !clusterAnalysisWatch { + utils.PrintlnInfo("Run 'qovery cluster analysis logs --cluster-id " + clusterAnalysisClusterId + " --analysis-id " + analysisId + "' to fetch the report once finished.") + return + } + + lastStatus := analysis.GetStatus() + for !isFinalAnalysisStatus(lastStatus) { + time.Sleep(5 * time.Second) + + current, res, err := client.ClustersAPI.GetClusterAnalysis(ctx, clusterAnalysisClusterId, analysisId).Execute() + if err != nil { + utils.PrintlnError(httpError(res, err)) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + + if current.GetStatus() != lastStatus { + lastStatus = current.GetStatus() + utils.Println("Status: " + string(lastStatus)) + } + + if isFinalAnalysisStatus(current.GetStatus()) { + lastStatus = current.GetStatus() + if errMsg := current.GetErrorMessage(); errMsg != "" { + utils.Println(pterm.Error.Sprintf("%s", errMsg)) + } + break + } + } + + if !clusterAnalysisNoLogs { + if err := printAnalysisLogs(client, clusterAnalysisClusterId, analysisId); err != nil { + utils.PrintlnError(err) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + } + + if lastStatus != qovery.CLUSTERANALYSISSTATUS_SUCCEEDED { + utils.Println(pterm.Error.Sprintf("Analysis %s ended with status %s", analysisId, string(lastStatus))) + os.Exit(1) + panic("unreachable") // staticcheck false positive: https://staticcheck.io/docs/checks#SA5011 + } + + utils.Println(pterm.FgGreen.Sprintf("Analysis %s succeeded", analysisId)) +} + +func addClusterAnalysisRunFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&clusterAnalysisClusterId, "cluster-id", "c", "", "Cluster ID") + cmd.Flags().StringVar(&clusterAnalysisOutputFormat, "output", "json", "Report output: table, json, csv") + cmd.Flags().BoolVar(&clusterAnalysisWatch, "watch", true, "Wait for the analysis to finish and print its report") + cmd.Flags().BoolVar(&clusterAnalysisNoLogs, "no-logs", false, "Do not print the report logs when finished") + _ = cmd.MarkFlagRequired("cluster-id") +} diff --git a/go.mod b/go.mod index 2e9bc3d6..690e55fa 100644 --- a/go.mod +++ b/go.mod @@ -24,7 +24,7 @@ require ( github.com/pkg/errors v0.9.1 github.com/posthog/posthog-go v1.12.5 github.com/pterm/pterm v0.12.83 - github.com/qovery/qovery-client-go v0.0.0-20260610153209-3c28b05bfe2b + github.com/qovery/qovery-client-go v0.0.0-20260625132707-e611218d15ba github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 diff --git a/go.sum b/go.sum index 0d8bb89f..40f0661b 100644 --- a/go.sum +++ b/go.sum @@ -189,10 +189,8 @@ github.com/posthog/posthog-go v1.12.5 h1:l/x3mpqisXJ0sTOyyRutsTQAgiWYuJT1uhN4cQr github.com/posthog/posthog-go v1.12.5/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg= github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= -github.com/qovery/qovery-client-go v0.0.0-20260609072636-f548ebe903f2 h1:R6lG3dFH9/N7k7Hz+MMMCY1y3c0N9rmY6NAAjy1dkos= -github.com/qovery/qovery-client-go v0.0.0-20260609072636-f548ebe903f2/go.mod h1:mcXeQtxR4AIGIBaWLhy52S16UwL8/1fcDywDuSK1BZ4= -github.com/qovery/qovery-client-go v0.0.0-20260610153209-3c28b05bfe2b h1:BEeLs9mqTI93TyzHPfAhhrn1aIyXVZzNwkQOI6bN3yc= -github.com/qovery/qovery-client-go v0.0.0-20260610153209-3c28b05bfe2b/go.mod h1:mcXeQtxR4AIGIBaWLhy52S16UwL8/1fcDywDuSK1BZ4= +github.com/qovery/qovery-client-go v0.0.0-20260625132707-e611218d15ba h1:J+LKk+v6XfbsDfUg8x6RXXVrCP8sd/hG5xYskxSRwUM= +github.com/qovery/qovery-client-go v0.0.0-20260625132707-e611218d15ba/go.mod h1:mcXeQtxR4AIGIBaWLhy52S16UwL8/1fcDywDuSK1BZ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=