From f2b14fd86c466b6bf273038325cf70069b1dbbbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Thu, 17 Jul 2025 10:40:46 +0200 Subject: [PATCH 01/22] Add stack ID and slug support to cloud login command --- cloudapi/config.go | 10 ++++ internal/cmd/cloud_login.go | 108 +++++++++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 8 deletions(-) diff --git a/cloudapi/config.go b/cloudapi/config.go index 0dd796c73c..4853e0c411 100644 --- a/cloudapi/config.go +++ b/cloudapi/config.go @@ -18,6 +18,8 @@ const LegacyCloudConfigKey = "loadimpact" //nolint:lll type Config struct { // TODO: refactor common stuff between cloud execution and output + StackID null.String `json:"stackID,omitempty" envconfig:"K6_CLOUD_STACK_ID"` + StackSlug null.String `json:"stackSlug,omitempty" envconfig:"K6_CLOUD_STACK_SLUG"` Token null.String `json:"token" envconfig:"K6_CLOUD_TOKEN"` ProjectID null.Int `json:"projectID" envconfig:"K6_CLOUD_PROJECT_ID"` Name null.String `json:"name" envconfig:"K6_CLOUD_NAME"` @@ -166,6 +168,14 @@ func (c Config) Apply(cfg Config) Config { if cfg.AggregationWaitPeriod.Valid { c.AggregationWaitPeriod = cfg.AggregationWaitPeriod } + if cfg.StackID.Valid { + c.StackID = cfg.StackID + c.StackSlug = null.StringFromPtr(nil) + } + if cfg.StackSlug.Valid { + c.StackSlug = cfg.StackSlug + c.StackID = null.StringFromPtr(nil) + } return c } diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 71783cb3a6..b62a1df286 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strconv" "syscall" "github.com/fatih/color" @@ -59,7 +60,9 @@ the "k6 run -o cloud" command. loginCloudCommand.Flags().StringP("token", "t", "", "specify `token` to use") loginCloudCommand.Flags().BoolP("show", "s", false, "display saved token and exit") - loginCloudCommand.Flags().BoolP("reset", "r", false, "reset stored token") + loginCloudCommand.Flags().BoolP("reset", "r", false, "reset stored token and stack info") + loginCloudCommand.Flags().String("stack-id", "", "set stack ID (cannot be used with --stack-slug)") + loginCloudCommand.Flags().String("stack-slug", "", "set stack slug (cannot be used with --stack-id)") return loginCloudCommand } @@ -92,23 +95,49 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { show := getNullBool(cmd.Flags(), "show") reset := getNullBool(cmd.Flags(), "reset") token := getNullString(cmd.Flags(), "token") + stackIDStr := cmd.Flags().Lookup("stack-id").Value.String() + stackSlug := getNullString(cmd.Flags(), "stack-slug") + + var stackIDInt int64 + var stackIDValid bool + if stackIDStr != "" { + var parseErr error + stackIDInt, parseErr = parseStackID(stackIDStr) + if parseErr != nil { + return fmt.Errorf("invalid --stack-id: %w", parseErr) + } + stackIDValid = true + } + if stackIDValid && stackSlug.Valid { + return errors.New("only one of --stack-id or --stack-slug can be specified") + } + switch { case reset.Valid: newCloudConf.Token = null.StringFromPtr(nil) - printToStdout(c.globalState, " token reset\n") + newCloudConf.StackID = null.StringFromPtr(nil) + newCloudConf.StackSlug = null.StringFromPtr(nil) + printToStdout(c.globalState, " token and stack info reset\n") + return nil case show.Bool: valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) + if newCloudConf.StackID.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.String))) + } + if newCloudConf.StackSlug.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) + } return nil case token.Valid: newCloudConf.Token = token default: - form := ui.Form{ + tokenForm := ui.Form{ Banner: "Enter your token to authenticate with Grafana Cloud k6.\n" + "Please, consult the Grafana Cloud k6 documentation for instructions on how to generate one:\n" + "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication", Fields: []ui.Field{ - ui.PasswordField{ + ui.StringField{ Key: "Token", Label: "Token", }, @@ -117,12 +146,60 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert c.globalState.Logger.Warn("Stdin is not a terminal, falling back to plain text input") } - var vals map[string]string - vals, err = form.Run(c.globalState.Stdin, c.globalState.Stdout) + tokenVals, err := tokenForm.Run(c.globalState.Stdin, c.globalState.Stdout) if err != nil { return err } - newCloudConf.Token = null.StringFrom(vals["Token"]) + tokenValue := tokenVals["Token"] + newCloudConf.Token = null.StringFrom(tokenValue) + + if newCloudConf.Token.Valid { + if err := validateToken(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String); err != nil { + return fmt.Errorf("token validation failed: %w", err) + } + } + + defaultStack := func(token string) string { + return "slug" + }(tokenValue) + + slugForm := ui.Form{ + Banner: "\nConfigure the stack where your tests will run by default.\n" + + "Please, consult the Grafana Cloud k6 documentation for instructions on how to find your stack slug:\n" + + // TODO: Update the link when the documentation is ready + "FIXME", + Fields: []ui.Field{ + ui.StringField{ + Key: "StackSlug", + Label: "Stack", + Default: defaultStack, + }, + }, + } + slugVals, err := slugForm.Run(c.globalState.Stdin, c.globalState.Stdout) + if err != nil { + return err + } + + stackValue := slugVals["StackSlug"] + stackSlugInput := null.StringFrom(stackValue) + + if stackSlugInput.Valid { + stackSlug = stackSlugInput + } else { + return errors.New("stack cannot be empty") + } + } + + if stackIDValid { + newCloudConf.StackID = null.StringFrom(fmt.Sprintf("%d", stackIDInt)) + newCloudConf.StackSlug = null.StringFromPtr(nil) + } else if stackSlug.Valid { + newCloudConf.StackSlug = stackSlug + newCloudConf.StackID = null.StringFromPtr(nil) + } else { + newCloudConf.StackID = null.StringFromPtr(nil) + newCloudConf.StackSlug = null.StringFromPtr(nil) } if newCloudConf.Token.Valid { @@ -144,9 +221,20 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { } if newCloudConf.Token.Valid { + valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) printToStdout(c.globalState, fmt.Sprintf( - "Logged in successfully, token saved in %s\n", c.globalState.Flags.ConfigFilePath, + "\nLogged in successfully, token and stack info saved in %s\n", c.globalState.Flags.ConfigFilePath, )) + if !c.globalState.Flags.Quiet { + printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) + + if newCloudConf.StackID.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.String))) + } + if newCloudConf.StackSlug.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) + } + } } return nil } @@ -186,3 +274,7 @@ func validateToken(gs *state.GlobalState, jsonRawConf json.RawMessage, token str return nil } + +func parseStackID(val string) (int64, error) { + return strconv.ParseInt(val, 10, 64) +} From 12ff28d4e037fa56d50faee888e4e7b54704b551 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Thu, 17 Jul 2025 11:12:15 +0200 Subject: [PATCH 02/22] Implement default stack in interactive mode --- cloudapi/api.go | 30 +++++++++++++++++ internal/cmd/cloud_login.go | 67 ++++++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 13 deletions(-) diff --git a/cloudapi/api.go b/cloudapi/api.go index 034fa1279a..4a7f0e9bdf 100644 --- a/cloudapi/api.go +++ b/cloudapi/api.go @@ -336,3 +336,33 @@ func (c *Client) ValidateToken() (*ValidateTokenResponse, error) { return &vtr, nil } + +type accountMeRequest struct { +} + +type Organization struct { + ID int `json:"id"` + GrafanaStackName string `json:"grafana_stack_name"` + GrafanaStackID int `json:"grafana_stack_id"` +} + +type accountMeResponse struct { + Organizations []Organization `json:"organizations"` +} + +// AccountMe retrieves the current user's account information. +func (c *Client) AccountMe() (*accountMeResponse, error) { + // TODO: remove this hardcoded URL + req, err := c.NewRequest("GET", "https://api.k6.io/v3/account/me", accountMeRequest{}) + if err != nil { + return nil, err + } + + amr := accountMeResponse{} + err = c.Do(req, &amr) + if err != nil { + return nil, err + } + + return &amr, nil +} diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index b62a1df286..ea276705b4 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -159,33 +159,41 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { } } - defaultStack := func(token string) string { - return "slug" + defaultStack, err := func(token string) (string, error) { + stacks, err := getStacks(c.globalState, currentJSONConfigRaw, token) + if err != nil { + return "", fmt.Errorf("failed to get default stack slug: %w", err) + } + + // TODO: Can we make this better? Picking the first one is not ideal. + for slug := range stacks { + return slug, nil + } + return "", errors.New("no stacks found for the provided token, please create a stack in Grafana Cloud and initialize GCk6 app") }(tokenValue) + if err != nil { + return fmt.Errorf("failed to get default stack: %w", err) + } - slugForm := ui.Form{ + stackForm := ui.Form{ Banner: "\nConfigure the stack where your tests will run by default.\n" + - "Please, consult the Grafana Cloud k6 documentation for instructions on how to find your stack slug:\n" + - // TODO: Update the link when the documentation is ready - "FIXME", + "Please, use the slug from your Grafana Cloud URL, e.g. my-team from https://my-team.grafana.net.\n", Fields: []ui.Field{ ui.StringField{ - Key: "StackSlug", + Key: "Stack", Label: "Stack", Default: defaultStack, }, }, } - slugVals, err := slugForm.Run(c.globalState.Stdin, c.globalState.Stdout) + stackVals, err := stackForm.Run(c.globalState.Stdin, c.globalState.Stdout) if err != nil { return err } - stackValue := slugVals["StackSlug"] - stackSlugInput := null.StringFrom(stackValue) - - if stackSlugInput.Valid { - stackSlug = stackSlugInput + stackValue := null.StringFrom(stackVals["Stack"]) + if stackValue.Valid { + stackSlug = stackValue } else { return errors.New("stack cannot be empty") } @@ -275,6 +283,39 @@ func validateToken(gs *state.GlobalState, jsonRawConf json.RawMessage, token str return nil } +func getStacks(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) (map[string]int, error) { + // We want to use this fully consolidated config for things like + // host addresses, so users can overwrite them with env vars. + consolidatedCurrentConfig, warn, err := cloudapi.GetConsolidatedConfig( + jsonRawConf, gs.Env, "", nil, nil) + if err != nil { + return nil, err + } + + if warn != "" { + gs.Logger.Warn(warn) + } + client := cloudapi.NewClient( + gs.Logger, + token, + "", + build.Version, + consolidatedCurrentConfig.Timeout.TimeDuration(), + ) + + res, err := client.AccountMe() + if err != nil { + return nil, fmt.Errorf("can't get account info: %s", err.Error()) + } + + stacks := make(map[string]int) + for _, organization := range res.Organizations { + stacks[organization.GrafanaStackName] = organization.GrafanaStackID + } + + return stacks, nil +} + func parseStackID(val string) (int64, error) { return strconv.ParseInt(val, 10, 64) } From 2813fd8a1987b2252feb4c9955c8afd8d40efdd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Thu, 17 Jul 2025 11:54:18 +0200 Subject: [PATCH 03/22] Don't run the migration twice --- internal/cmd/cloud_login.go | 8 +++++--- internal/cmd/config.go | 13 +++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index ea276705b4..4aca7b15ff 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -69,9 +69,11 @@ the "k6 run -o cloud" command. // run is the code that runs when the user executes `k6 cloud login` func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { - err := migrateLegacyConfigFileIfAny(c.globalState) - if err != nil { - return err + if !checkIfMigrationCompleted(c.globalState) { + err := migrateLegacyConfigFileIfAny(c.globalState) + if err != nil { + return err + } } currentDiskConf, err := readDiskConfig(c.globalState) diff --git a/internal/cmd/config.go b/internal/cmd/config.go index 1d1126af54..0fe246894b 100644 --- a/internal/cmd/config.go +++ b/internal/cmd/config.go @@ -404,3 +404,16 @@ func migrateLegacyConfigFileIfAny(gs *state.GlobalState) error { } return nil } + +// checkIfMigrationCompleted checks if the migration has been done by checking if the new file exists). +func checkIfMigrationCompleted(gs *state.GlobalState) bool { + _, err := gs.FS.Stat(gs.DefaultFlags.ConfigFilePath) + if errors.Is(err, fs.ErrNotExist) { + return false + } + if err != nil { + gs.Logger.Errorf("Failed to check if the migration has been done: %v", err) + return false + } + return true +} From 671c4017f9b3ae0bf2b8e05456a1fe504710af81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Thu, 17 Jul 2025 11:56:34 +0200 Subject: [PATCH 04/22] Update examples, other flags and hide the ID from users --- internal/cmd/cloud_login.go | 112 +++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 52 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 4aca7b15ff..af90aa0f79 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "strconv" "syscall" "github.com/fatih/color" @@ -31,16 +30,19 @@ func getCmdCloudLogin(gs *state.GlobalState) *cobra.Command { // loginCloudCommand represents the 'cloud login' command exampleText := getExampleText(gs, ` - # Prompt for a Grafana Cloud k6 token + # Authenticate interactively with Grafana Cloud k6 $ {{.}} cloud login # Store a token in k6's persistent configuration $ {{.}} cloud login -t - # Display the stored token + # Store a token in k6's persistent configuration and set the stack slug + $ {{.}} cloud login -t -s + + # Display the stored token and stack info $ {{.}} cloud login -s - # Reset the stored token + # Reset the stored token and stack info $ {{.}} cloud login -r`[1:]) loginCloudCommand := &cobra.Command{ @@ -59,10 +61,9 @@ the "k6 run -o cloud" command. } loginCloudCommand.Flags().StringP("token", "t", "", "specify `token` to use") - loginCloudCommand.Flags().BoolP("show", "s", false, "display saved token and exit") + loginCloudCommand.Flags().BoolP("show", "s", false, "display saved token, stack info and exit") loginCloudCommand.Flags().BoolP("reset", "r", false, "reset stored token and stack info") - loginCloudCommand.Flags().String("stack-id", "", "set stack ID (cannot be used with --stack-slug)") - loginCloudCommand.Flags().String("stack-slug", "", "set stack slug (cannot be used with --stack-id)") + loginCloudCommand.Flags().String("stack-slug", "", "specify the stack where commands will run by default") return loginCloudCommand } @@ -97,23 +98,8 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { show := getNullBool(cmd.Flags(), "show") reset := getNullBool(cmd.Flags(), "reset") token := getNullString(cmd.Flags(), "token") - stackIDStr := cmd.Flags().Lookup("stack-id").Value.String() stackSlug := getNullString(cmd.Flags(), "stack-slug") - var stackIDInt int64 - var stackIDValid bool - if stackIDStr != "" { - var parseErr error - stackIDInt, parseErr = parseStackID(stackIDStr) - if parseErr != nil { - return fmt.Errorf("invalid --stack-id: %w", parseErr) - } - stackIDValid = true - } - if stackIDValid && stackSlug.Valid { - return errors.New("only one of --stack-id or --stack-slug can be specified") - } - switch { case reset.Valid: newCloudConf.Token = null.StringFromPtr(nil) @@ -124,15 +110,26 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { case show.Bool: valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) - if newCloudConf.StackID.Valid { + if !newCloudConf.StackID.Valid && !newCloudConf.StackSlug.Valid { + printToStdout(c.globalState, " stack-id: \n") + printToStdout(c.globalState, " stack-slug: \n") + } else { printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.String))) - } - if newCloudConf.StackSlug.Valid { printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) } return nil case token.Valid: newCloudConf.Token = token + if stackSlug.Valid { + normalizedSlug := stripGrafanaNetSuffix(stackSlug.String) + newCloudConf.StackSlug = null.StringFrom(normalizedSlug) + id, err := resolveStackSlugToID(c.globalState, currentJSONConfigRaw, token.String, normalizedSlug) + if err == nil { + newCloudConf.StackID = null.StringFrom(fmt.Sprintf("%d", id)) + } else { + return fmt.Errorf("could not resolve stack slug. Are you sure the slug is correct? %w", err) + } + } default: tokenForm := ui.Form{ Banner: "Enter your token to authenticate with Grafana Cloud k6.\n" + @@ -155,10 +152,11 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { tokenValue := tokenVals["Token"] newCloudConf.Token = null.StringFrom(tokenValue) - if newCloudConf.Token.Valid { - if err := validateToken(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String); err != nil { - return fmt.Errorf("token validation failed: %w", err) - } + if !newCloudConf.Token.Valid { + return errors.New("token cannot be empty") + } + if err := validateToken(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String); err != nil { + return fmt.Errorf("token validation failed: %w", err) } defaultStack, err := func(token string) (string, error) { @@ -166,8 +164,6 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { if err != nil { return "", fmt.Errorf("failed to get default stack slug: %w", err) } - - // TODO: Can we make this better? Picking the first one is not ideal. for slug := range stacks { return slug, nil } @@ -178,11 +174,11 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { } stackForm := ui.Form{ - Banner: "\nConfigure the stack where your tests will run by default.\n" + - "Please, use the slug from your Grafana Cloud URL, e.g. my-team from https://my-team.grafana.net.\n", + Banner: "\nEnter the stack where you want to run k6's commands by default.\n" + + "Use the slug from your Grafana Cloud URL, e.g. my-team from https://my-team.grafana.net):", Fields: []ui.Field{ ui.StringField{ - Key: "Stack", + Key: "StackSlug", Label: "Stack", Default: defaultStack, }, @@ -193,23 +189,18 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return err } - stackValue := null.StringFrom(stackVals["Stack"]) - if stackValue.Valid { - stackSlug = stackValue - } else { + stackSlugInput := null.StringFrom(stackVals["StackSlug"]) + if !stackSlugInput.Valid { return errors.New("stack cannot be empty") } - } + normalizedSlug := stripGrafanaNetSuffix(stackSlugInput.String) + newCloudConf.StackSlug = null.StringFrom(normalizedSlug) - if stackIDValid { - newCloudConf.StackID = null.StringFrom(fmt.Sprintf("%d", stackIDInt)) - newCloudConf.StackSlug = null.StringFromPtr(nil) - } else if stackSlug.Valid { - newCloudConf.StackSlug = stackSlug - newCloudConf.StackID = null.StringFromPtr(nil) - } else { - newCloudConf.StackID = null.StringFromPtr(nil) - newCloudConf.StackSlug = null.StringFromPtr(nil) + id, err := resolveStackSlugToID(c.globalState, currentJSONConfigRaw, tokenValue, normalizedSlug) + if err != nil { + return fmt.Errorf("could not resolve stack slug. Are you sure the slug is correct? %w", err) + } + newCloudConf.StackID = null.StringFrom(fmt.Sprintf("%d", id)) } if newCloudConf.Token.Valid { @@ -285,6 +276,14 @@ func validateToken(gs *state.GlobalState, jsonRawConf json.RawMessage, token str return nil } +func stripGrafanaNetSuffix(s string) string { + const suffix = ".grafana.net" + if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix { + return s[:len(s)-len(suffix)] + } + return s +} + func getStacks(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) (map[string]int, error) { // We want to use this fully consolidated config for things like // host addresses, so users can overwrite them with env vars. @@ -312,12 +311,21 @@ func getStacks(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) stacks := make(map[string]int) for _, organization := range res.Organizations { - stacks[organization.GrafanaStackName] = organization.GrafanaStackID + stackName := stripGrafanaNetSuffix(organization.GrafanaStackName) + stacks[stackName] = organization.GrafanaStackID } - return stacks, nil } -func parseStackID(val string) (int64, error) { - return strconv.ParseInt(val, 10, 64) +func resolveStackSlugToID(gs *state.GlobalState, jsonRawConf json.RawMessage, token, slug string) (int64, error) { + slug = stripGrafanaNetSuffix(slug) + stacks, err := getStacks(gs, jsonRawConf, token) + if err != nil { + return 0, err + } + id, ok := stacks[slug] + if !ok { + return 0, fmt.Errorf("stack slug %q not found in your Grafana Cloud account", slug) + } + return int64(id), nil } From 12fc9072cc694f5fb5ce8b0d9e50ccaf76f4b679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Thu, 17 Jul 2025 16:21:02 +0200 Subject: [PATCH 05/22] Add support for retrieving default projectID based on stackID --- cloudapi/api.go | 57 +++++++++++++++++++++++++++++++++++ cloudapi/config.go | 22 ++++++++------ cloudapi/config_test.go | 2 ++ internal/cmd/cloud.go | 20 ++++++++++++ internal/cmd/cloud_login.go | 10 +++--- internal/cmd/outputs_cloud.go | 8 +++++ 6 files changed, 104 insertions(+), 15 deletions(-) diff --git a/cloudapi/api.go b/cloudapi/api.go index 4a7f0e9bdf..467a969add 100644 --- a/cloudapi/api.go +++ b/cloudapi/api.go @@ -2,7 +2,9 @@ package cloudapi import ( "bytes" + "encoding/json" "fmt" + "io" "mime/multipart" "net/http" "strconv" @@ -366,3 +368,58 @@ func (c *Client) AccountMe() (*accountMeResponse, error) { return &amr, nil } + +func (c *Client) GetDefaultProject(stack_id int64) (int64, string, error) { + // TODO: remove this hardcoded URL + req, err := c.NewRequest("GET", "https://api.k6.io/cloud/v6/projects", nil) + if err != nil { + return 0, "", err + } + + q := req.URL.Query() + q.Add("$orderby", "created") + req.URL.RawQuery = q.Encode() + + req.Header.Set("X-Stack-Id", fmt.Sprintf("%d", stack_id)) + // TODO: by default the client uses Token instead of Bearer + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) + req.Header.Set("User-Agent", "Go-http-client") + + // TODO: Can't use c.Do bc it messes up the headers + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return 0, "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var parsed struct { + Count int64 `json:"@count"` + Value []struct { + ID int64 `json:"id"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + FolderUID string `json:"grafana_folder_uid"` + } `json:"value"` + } + + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return 0, "", fmt.Errorf("failed to decode response: %w", err) + } + + if len(parsed.Value) == 0 { + return 0, "", fmt.Errorf("no projects found for stack ID %d", stack_id) + } + + for _, proj := range parsed.Value { + if proj.IsDefault { + return proj.ID, proj.Name, nil + } + } + + return 0, "", fmt.Errorf("no default project found for stack ID %d", stack_id) +} diff --git a/cloudapi/config.go b/cloudapi/config.go index 4853e0c411..c8d6475fe6 100644 --- a/cloudapi/config.go +++ b/cloudapi/config.go @@ -18,7 +18,7 @@ const LegacyCloudConfigKey = "loadimpact" //nolint:lll type Config struct { // TODO: refactor common stuff between cloud execution and output - StackID null.String `json:"stackID,omitempty" envconfig:"K6_CLOUD_STACK_ID"` + StackID null.Int `json:"stackID,omitempty" envconfig:"K6_CLOUD_STACK_ID"` StackSlug null.String `json:"stackSlug,omitempty" envconfig:"K6_CLOUD_STACK_SLUG"` Token null.String `json:"token" envconfig:"K6_CLOUD_TOKEN"` ProjectID null.Int `json:"projectID" envconfig:"K6_CLOUD_PROJECT_ID"` @@ -105,6 +105,12 @@ func NewConfig() Config { // //nolint:cyclop func (c Config) Apply(cfg Config) Config { + if cfg.StackID.Valid { + c.StackID = cfg.StackID + } + if cfg.StackSlug.Valid { + c.StackSlug = cfg.StackSlug + } if cfg.Token.Valid { c.Token = cfg.Token } @@ -168,14 +174,6 @@ func (c Config) Apply(cfg Config) Config { if cfg.AggregationWaitPeriod.Valid { c.AggregationWaitPeriod = cfg.AggregationWaitPeriod } - if cfg.StackID.Valid { - c.StackID = cfg.StackID - c.StackSlug = null.StringFromPtr(nil) - } - if cfg.StackSlug.Valid { - c.StackSlug = cfg.StackSlug - c.StackID = null.StringFromPtr(nil) - } return c } @@ -250,7 +248,8 @@ func mergeFromCloudOptionAndExternal( if err := json.Unmarshal(source, &tmpConfig); err != nil { return err } - // Only take out the ProjectID, Name and Token from the options.cloud (or legacy loadimpact struct) map: + + // Only take out the ProjectID, Name, Token and StackID from the options.cloud (or legacy loadimpact struct) map: if tmpConfig.ProjectID.Valid { conf.ProjectID = tmpConfig.ProjectID } @@ -260,6 +259,9 @@ func mergeFromCloudOptionAndExternal( if tmpConfig.Token.Valid { conf.Token = tmpConfig.Token } + if tmpConfig.StackID.Valid { + conf.StackID = tmpConfig.StackID + } return nil } diff --git a/cloudapi/config_test.go b/cloudapi/config_test.go index 95d8627e02..a45ce5c7c7 100644 --- a/cloudapi/config_test.go +++ b/cloudapi/config_test.go @@ -25,6 +25,8 @@ func TestConfigApply(t *testing.T) { full := Config{ Token: null.NewString("Token", true), + StackID: null.NewInt(1, true), + StackSlug: null.NewString("StackSlug", true), ProjectID: null.NewInt(1, true), Name: null.NewString("Name", true), Host: null.NewString("Host", true), diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 57842067eb..60b7fc85df 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -19,6 +19,7 @@ import ( "go.k6.io/k6/internal/build" "go.k6.io/k6/internal/ui/pb" "go.k6.io/k6/lib" + "gopkg.in/guregu/null.v3" "github.com/fatih/color" "github.com/spf13/cobra" @@ -165,6 +166,25 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { modifyAndPrintBar(c.gs, progressBar, pb.WithConstProgress(0, "Validating script options")) client := cloudapi.NewClient( logger, cloudConfig.Token.String, cloudConfig.Host.String, build.Version, cloudConfig.Timeout.TimeDuration()) + + if cloudConfig.ProjectID.Int64 == 0 { + projectID, _, err := client.GetDefaultProject(cloudConfig.StackID.Int64) + if err != nil { + return fmt.Errorf("can't get default projectID for stack %d (%s): %w", cloudConfig.StackID.Int64, cloudConfig.StackSlug.String, err) + } + tmpCloudConfig["projectID"] = projectID + + b, err := json.Marshal(tmpCloudConfig) + if err != nil { + return err + } + + arc.Options.Cloud = b + arc.Options.External[cloudapi.LegacyCloudConfigKey] = b + + cloudConfig.ProjectID = null.IntFrom(projectID) + } + if err = client.ValidateOptions(arc.Options); err != nil { return err } diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index af90aa0f79..f276c8bffe 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -103,7 +103,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { switch { case reset.Valid: newCloudConf.Token = null.StringFromPtr(nil) - newCloudConf.StackID = null.StringFromPtr(nil) + newCloudConf.StackID = null.IntFromPtr(nil) newCloudConf.StackSlug = null.StringFromPtr(nil) printToStdout(c.globalState, " token and stack info reset\n") return nil @@ -114,7 +114,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { printToStdout(c.globalState, " stack-id: \n") printToStdout(c.globalState, " stack-slug: \n") } else { - printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.String))) + printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.Int64))) printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) } return nil @@ -125,7 +125,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { newCloudConf.StackSlug = null.StringFrom(normalizedSlug) id, err := resolveStackSlugToID(c.globalState, currentJSONConfigRaw, token.String, normalizedSlug) if err == nil { - newCloudConf.StackID = null.StringFrom(fmt.Sprintf("%d", id)) + newCloudConf.StackID = null.IntFrom(id) } else { return fmt.Errorf("could not resolve stack slug. Are you sure the slug is correct? %w", err) } @@ -200,7 +200,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("could not resolve stack slug. Are you sure the slug is correct? %w", err) } - newCloudConf.StackID = null.StringFrom(fmt.Sprintf("%d", id)) + newCloudConf.StackID = null.IntFrom(id) } if newCloudConf.Token.Valid { @@ -230,7 +230,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) if newCloudConf.StackID.Valid { - printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.String))) + printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.Int64))) } if newCloudConf.StackSlug.Valid { printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index 2d0eac0387..3c612ab627 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -118,6 +118,14 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error apiClient := cloudapi.NewClient( logger, conf.Token.String, conf.Host.String, build.Version, conf.Timeout.TimeDuration()) + if testRun.ProjectID == 0 { + projectID, _, err := apiClient.GetDefaultProject(conf.StackID.Int64) + if err != nil { + return fmt.Errorf("can't get default projectID for stack %d (%s): %w", conf.StackID.Int64, conf.StackSlug.String, err) + } + testRun.ProjectID = projectID + } + response, err := apiClient.CreateTestRun(testRun) if err != nil { return err From ff92bc438c56f854aabb4669da600515111a28d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Thu, 17 Jul 2025 16:32:48 +0200 Subject: [PATCH 06/22] Add warning when default projectID is used --- internal/cmd/cloud.go | 2 ++ internal/cmd/outputs_cloud.go | 2 ++ 2 files changed, 4 insertions(+) diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 60b7fc85df..2a9849e155 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -183,6 +183,8 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { arc.Options.External[cloudapi.LegacyCloudConfigKey] = b cloudConfig.ProjectID = null.IntFrom(projectID) + + logger.Warn("Warning: no projectID specified, using default project of the stack: " + cloudConfig.StackSlug.String) } if err = client.ValidateOptions(arc.Options); err != nil { diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index 3c612ab627..c0f9afb47b 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -124,6 +124,8 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error return fmt.Errorf("can't get default projectID for stack %d (%s): %w", conf.StackID.Int64, conf.StackSlug.String, err) } testRun.ProjectID = projectID + + gs.Logger.Warn("Warning: no projectID specified, using default project of the stack: " + conf.StackSlug.String) } response, err := apiClient.CreateTestRun(testRun) From db0134af3e8ab34bca2d8ade23ef3e06367f47ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:00:05 +0200 Subject: [PATCH 07/22] Handle lack of stack info --- internal/cmd/cloud.go | 5 +++++ internal/cmd/outputs_cloud.go | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 2a9849e155..5fe9df29dc 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -168,6 +168,11 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { logger, cloudConfig.Token.String, cloudConfig.Host.String, build.Version, cloudConfig.Timeout.TimeDuration()) if cloudConfig.ProjectID.Int64 == 0 { + if cloudConfig.StackID.Int64 == 0 { + logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") + return fmt.Errorf("no projectID specified and no default stack set") + } + projectID, _, err := client.GetDefaultProject(cloudConfig.StackID.Int64) if err != nil { return fmt.Errorf("can't get default projectID for stack %d (%s): %w", cloudConfig.StackID.Int64, cloudConfig.StackSlug.String, err) diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index c0f9afb47b..87a0eac528 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -119,6 +119,11 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error logger, conf.Token.String, conf.Host.String, build.Version, conf.Timeout.TimeDuration()) if testRun.ProjectID == 0 { + if conf.StackID.Int64 == 0 { + gs.Logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") + return fmt.Errorf("no projectID specified and no default stack set") + } + projectID, _, err := apiClient.GetDefaultProject(conf.StackID.Int64) if err != nil { return fmt.Errorf("can't get default projectID for stack %d (%s): %w", conf.StackID.Int64, conf.StackSlug.String, err) From 6f23366519eb3a1a395feb153ad2653ee7de43cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:13:42 +0200 Subject: [PATCH 08/22] Add support for setting slug in the script --- cloudapi/config.go | 3 +++ internal/cmd/cloud.go | 12 ++++++++++-- internal/cmd/outputs_cloud.go | 12 ++++++++++-- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/cloudapi/config.go b/cloudapi/config.go index c8d6475fe6..3659c82626 100644 --- a/cloudapi/config.go +++ b/cloudapi/config.go @@ -262,6 +262,9 @@ func mergeFromCloudOptionAndExternal( if tmpConfig.StackID.Valid { conf.StackID = tmpConfig.StackID } + if tmpConfig.StackSlug.Valid { + conf.StackSlug = tmpConfig.StackSlug + } return nil } diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 5fe9df29dc..f5e61874d8 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -169,8 +169,16 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { if cloudConfig.ProjectID.Int64 == 0 { if cloudConfig.StackID.Int64 == 0 { - logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") - return fmt.Errorf("no projectID specified and no default stack set") + if cloudConfig.StackSlug.Valid && cloudConfig.StackSlug.String != "" { + id, err := resolveStackSlugToID(c.gs, test.derivedConfig.Collectors["cloud"], cloudConfig.Token.String, cloudConfig.StackSlug.String) + if err != nil { + return fmt.Errorf("could not resolve stack slug %q to stack ID: %w", cloudConfig.StackSlug.String, err) + } + cloudConfig.StackID = null.IntFrom(id) + } else { + logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") + return fmt.Errorf("no projectID specified and no default stack set") + } } projectID, _, err := client.GetDefaultProject(cloudConfig.StackID.Int64) diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index 87a0eac528..301ab8e640 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -120,8 +120,16 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error if testRun.ProjectID == 0 { if conf.StackID.Int64 == 0 { - gs.Logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") - return fmt.Errorf("no projectID specified and no default stack set") + if conf.StackSlug.Valid && conf.StackSlug.String != "" { + id, err := resolveStackSlugToID(gs, test.derivedConfig.Collectors["cloud"], conf.Token.String, conf.StackSlug.String) + if err != nil { + return fmt.Errorf("could not resolve stack slug %q to stack ID: %w", conf.StackSlug.String, err) + } + conf.StackID = null.IntFrom(id) + } else { + logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") + return fmt.Errorf("no projectID specified and no default stack set") + } } projectID, _, err := apiClient.GetDefaultProject(conf.StackID.Int64) From 2f92dc5138f647bdc6a7ee092ffa619bfc7046f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:33:20 +0200 Subject: [PATCH 09/22] Move shared logic to common --- internal/cmd/cloud.go | 29 +++++++---------- internal/cmd/cloud_login.go | 21 ------------ internal/cmd/common.go | 61 +++++++++++++++++++++++++++++++++++ internal/cmd/outputs_cloud.go | 29 +++++++---------- 4 files changed, 85 insertions(+), 55 deletions(-) diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index f5e61874d8..5200ecdb3c 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -168,23 +168,20 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { logger, cloudConfig.Token.String, cloudConfig.Host.String, build.Version, cloudConfig.Timeout.TimeDuration()) if cloudConfig.ProjectID.Int64 == 0 { - if cloudConfig.StackID.Int64 == 0 { - if cloudConfig.StackSlug.Valid && cloudConfig.StackSlug.String != "" { - id, err := resolveStackSlugToID(c.gs, test.derivedConfig.Collectors["cloud"], cloudConfig.Token.String, cloudConfig.StackSlug.String) - if err != nil { - return fmt.Errorf("could not resolve stack slug %q to stack ID: %w", cloudConfig.StackSlug.String, err) - } - cloudConfig.StackID = null.IntFrom(id) - } else { - logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") - return fmt.Errorf("no projectID specified and no default stack set") - } - } - - projectID, _, err := client.GetDefaultProject(cloudConfig.StackID.Int64) + projectID, err := resolveDefaultProjectID( + logger, + c.gs, + client, + test.derivedConfig.Collectors["cloud"], + cloudConfig.Token.String, + cloudConfig.StackSlug, + &cloudConfig.StackID, + &cloudConfig.ProjectID.Int64, + ) if err != nil { - return fmt.Errorf("can't get default projectID for stack %d (%s): %w", cloudConfig.StackID.Int64, cloudConfig.StackSlug.String, err) + return err } + tmpCloudConfig["projectID"] = projectID b, err := json.Marshal(tmpCloudConfig) @@ -196,8 +193,6 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { arc.Options.External[cloudapi.LegacyCloudConfigKey] = b cloudConfig.ProjectID = null.IntFrom(projectID) - - logger.Warn("Warning: no projectID specified, using default project of the stack: " + cloudConfig.StackSlug.String) } if err = client.ValidateOptions(arc.Options); err != nil { diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index f276c8bffe..bb0e4e13d7 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -276,14 +276,6 @@ func validateToken(gs *state.GlobalState, jsonRawConf json.RawMessage, token str return nil } -func stripGrafanaNetSuffix(s string) string { - const suffix = ".grafana.net" - if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix { - return s[:len(s)-len(suffix)] - } - return s -} - func getStacks(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) (map[string]int, error) { // We want to use this fully consolidated config for things like // host addresses, so users can overwrite them with env vars. @@ -316,16 +308,3 @@ func getStacks(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) } return stacks, nil } - -func resolveStackSlugToID(gs *state.GlobalState, jsonRawConf json.RawMessage, token, slug string) (int64, error) { - slug = stripGrafanaNetSuffix(slug) - stacks, err := getStacks(gs, jsonRawConf, token) - if err != nil { - return 0, err - } - id, ok := stacks[slug] - if !ok { - return 0, fmt.Errorf("stack slug %q not found in your Grafana Cloud account", slug) - } - return int64(id), nil -} diff --git a/internal/cmd/common.go b/internal/cmd/common.go index 896db9b9f2..faf334dcad 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -2,15 +2,18 @@ package cmd import ( "bytes" + "encoding/json" "fmt" "os" "syscall" "text/template" + "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cloudapi" "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib/types" @@ -127,3 +130,61 @@ func handleTestAbortSignals(gs *state.GlobalState, gracefulStopHandler, onHardSt gs.SignalStop(sigC) } } + +func resolveStackSlugToID(gs *state.GlobalState, jsonRawConf json.RawMessage, token, slug string) (int64, error) { + slug = stripGrafanaNetSuffix(slug) + stacks, err := getStacks(gs, jsonRawConf, token) + if err != nil { + return 0, err + } + id, ok := stacks[slug] + if !ok { + return 0, fmt.Errorf("stack slug %q not found in your Grafana Cloud account", slug) + } + return int64(id), nil +} + +func stripGrafanaNetSuffix(s string) string { + const suffix = ".grafana.net" + if len(s) > len(suffix) && s[len(s)-len(suffix):] == suffix { + return s[:len(s)-len(suffix)] + } + return s +} + +func resolveDefaultProjectID( + logger *logrus.Logger, + gs *state.GlobalState, + apiClient *cloudapi.Client, + jsonRawConf json.RawMessage, + token string, + stackSlug null.String, + stackID *null.Int, + projectID *int64, +) (int64, error) { + if *projectID != 0 { + return *projectID, nil + } + + if stackID.Int64 == 0 { + if stackSlug.Valid && stackSlug.String != "" { + id, err := resolveStackSlugToID(gs, jsonRawConf, token, stackSlug.String) + if err != nil { + return 0, fmt.Errorf("could not resolve stack slug %q to stack ID: %w", stackSlug.String, err) + } + *stackID = null.IntFrom(id) + } else { + logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") + return 0, fmt.Errorf("no projectID specified and no default stack set") + } + } + + pid, _, err := apiClient.GetDefaultProject(stackID.Int64) + if err != nil { + return 0, fmt.Errorf("can't get default projectID for stack %d (%s): %w", stackID.Int64, stackSlug.String, err) + } + *projectID = pid + + logger.Warnf("Warning: no projectID specified, using default project of the stack: %s \n\n", stackSlug.String) + return pid, nil +} diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index 301ab8e640..36ffaacefe 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -119,26 +119,21 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error logger, conf.Token.String, conf.Host.String, build.Version, conf.Timeout.TimeDuration()) if testRun.ProjectID == 0 { - if conf.StackID.Int64 == 0 { - if conf.StackSlug.Valid && conf.StackSlug.String != "" { - id, err := resolveStackSlugToID(gs, test.derivedConfig.Collectors["cloud"], conf.Token.String, conf.StackSlug.String) - if err != nil { - return fmt.Errorf("could not resolve stack slug %q to stack ID: %w", conf.StackSlug.String, err) - } - conf.StackID = null.IntFrom(id) - } else { - logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") - return fmt.Errorf("no projectID specified and no default stack set") - } - } - - projectID, _, err := apiClient.GetDefaultProject(conf.StackID.Int64) + projectID, err := resolveDefaultProjectID( + gs.Logger, + gs, + apiClient, + test.derivedConfig.Collectors["cloud"], + conf.Token.String, + conf.StackSlug, + &conf.StackID, + &testRun.ProjectID, + ) if err != nil { - return fmt.Errorf("can't get default projectID for stack %d (%s): %w", conf.StackID.Int64, conf.StackSlug.String, err) + return err } - testRun.ProjectID = projectID - gs.Logger.Warn("Warning: no projectID specified, using default project of the stack: " + conf.StackSlug.String) + testRun.ProjectID = projectID } response, err := apiClient.CreateTestRun(testRun) From d0487932fcb66ffd1c28146c5e1c4df42e1e86aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:39:57 +0200 Subject: [PATCH 10/22] Improve response handling in GetDefaultProject (errcheck) --- cloudapi/api.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cloudapi/api.go b/cloudapi/api.go index 467a969add..4a611f2050 100644 --- a/cloudapi/api.go +++ b/cloudapi/api.go @@ -390,7 +390,14 @@ func (c *Client) GetDefaultProject(stack_id int64) (int64, string, error) { if err != nil { return 0, "", fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() + defer func() { + if resp != nil { + _, _ = io.Copy(io.Discard, resp.Body) + if cerr := resp.Body.Close(); cerr != nil && err == nil { + err = cerr + } + } + }() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) From 408f03bf5dd625d7fa4675da497620656bff237b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:47:12 +0200 Subject: [PATCH 11/22] Keep making linter happy --- cloudapi/api.go | 13 ++++++------- internal/cmd/cloud_login.go | 2 ++ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cloudapi/api.go b/cloudapi/api.go index 4a611f2050..e23db5c096 100644 --- a/cloudapi/api.go +++ b/cloudapi/api.go @@ -339,28 +339,27 @@ func (c *Client) ValidateToken() (*ValidateTokenResponse, error) { return &vtr, nil } -type accountMeRequest struct { -} - +// Organization represents a Grafana Cloud k6 organization type Organization struct { ID int `json:"id"` GrafanaStackName string `json:"grafana_stack_name"` GrafanaStackID int `json:"grafana_stack_id"` } -type accountMeResponse struct { +// AccountMeResponse represents the response of the /account/me endpoint. +type AccountMeResponse struct { Organizations []Organization `json:"organizations"` } // AccountMe retrieves the current user's account information. -func (c *Client) AccountMe() (*accountMeResponse, error) { +func (c *Client) AccountMe() (*AccountMeResponse, error) { // TODO: remove this hardcoded URL - req, err := c.NewRequest("GET", "https://api.k6.io/v3/account/me", accountMeRequest{}) + req, err := c.NewRequest("GET", "https://api.k6.io/v3/account/me", nil) if err != nil { return nil, err } - amr := accountMeResponse{} + amr := AccountMeResponse{} err = c.Do(req, &amr) if err != nil { return nil, err diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index bb0e4e13d7..3ffc67e271 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -69,6 +69,8 @@ the "k6 run -o cloud" command. } // run is the code that runs when the user executes `k6 cloud login` +// +//nolint:funlen,gocognit,cyclop func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { if !checkIfMigrationCompleted(c.globalState) { err := migrateLegacyConfigFileIfAny(c.globalState) From f0cd139bb2e11e4a404d867b59d8afa329a0e5a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:48:02 +0200 Subject: [PATCH 12/22] Don't use underscores --- cloudapi/api.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cloudapi/api.go b/cloudapi/api.go index e23db5c096..a51f62bacf 100644 --- a/cloudapi/api.go +++ b/cloudapi/api.go @@ -368,7 +368,7 @@ func (c *Client) AccountMe() (*AccountMeResponse, error) { return &amr, nil } -func (c *Client) GetDefaultProject(stack_id int64) (int64, string, error) { +func (c *Client) GetDefaultProject(stackID int64) (int64, string, error) { // TODO: remove this hardcoded URL req, err := c.NewRequest("GET", "https://api.k6.io/cloud/v6/projects", nil) if err != nil { @@ -379,7 +379,7 @@ func (c *Client) GetDefaultProject(stack_id int64) (int64, string, error) { q.Add("$orderby", "created") req.URL.RawQuery = q.Encode() - req.Header.Set("X-Stack-Id", fmt.Sprintf("%d", stack_id)) + req.Header.Set("X-Stack-Id", fmt.Sprintf("%d", stackID)) // TODO: by default the client uses Token instead of Bearer req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.token)) req.Header.Set("User-Agent", "Go-http-client") @@ -418,7 +418,7 @@ func (c *Client) GetDefaultProject(stack_id int64) (int64, string, error) { } if len(parsed.Value) == 0 { - return 0, "", fmt.Errorf("no projects found for stack ID %d", stack_id) + return 0, "", fmt.Errorf("no projects found for stack ID %d", stackID) } for _, proj := range parsed.Value { @@ -427,5 +427,5 @@ func (c *Client) GetDefaultProject(stack_id int64) (int64, string, error) { } } - return 0, "", fmt.Errorf("no default project found for stack ID %d", stack_id) + return 0, "", fmt.Errorf("no default project found for stack ID %d", stackID) } From 79806d72d44602529f81da986c093e3578815d03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:50:36 +0200 Subject: [PATCH 13/22] Don't use logrus directly --- internal/cmd/cloud.go | 1 - internal/cmd/common.go | 6 ++---- internal/cmd/outputs_cloud.go | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/internal/cmd/cloud.go b/internal/cmd/cloud.go index 5200ecdb3c..60937b07a9 100644 --- a/internal/cmd/cloud.go +++ b/internal/cmd/cloud.go @@ -169,7 +169,6 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { if cloudConfig.ProjectID.Int64 == 0 { projectID, err := resolveDefaultProjectID( - logger, c.gs, client, test.derivedConfig.Collectors["cloud"], diff --git a/internal/cmd/common.go b/internal/cmd/common.go index faf334dcad..b8bf313846 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -8,7 +8,6 @@ import ( "syscall" "text/template" - "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/pflag" "gopkg.in/guregu/null.v3" @@ -153,7 +152,6 @@ func stripGrafanaNetSuffix(s string) string { } func resolveDefaultProjectID( - logger *logrus.Logger, gs *state.GlobalState, apiClient *cloudapi.Client, jsonRawConf json.RawMessage, @@ -174,7 +172,7 @@ func resolveDefaultProjectID( } *stackID = null.IntFrom(id) } else { - logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") + gs.Logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") return 0, fmt.Errorf("no projectID specified and no default stack set") } } @@ -185,6 +183,6 @@ func resolveDefaultProjectID( } *projectID = pid - logger.Warnf("Warning: no projectID specified, using default project of the stack: %s \n\n", stackSlug.String) + gs.Logger.Warnf("Warning: no projectID specified, using default project of the stack: %s \n\n", stackSlug.String) return pid, nil } diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index 36ffaacefe..a630314dde 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -120,7 +120,6 @@ func createCloudTest(gs *state.GlobalState, test *loadedAndConfiguredTest) error if testRun.ProjectID == 0 { projectID, err := resolveDefaultProjectID( - gs.Logger, gs, apiClient, test.derivedConfig.Collectors["cloud"], From dc427dd1501d8dad8c45657d2ddf19b59376448f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:51:15 +0200 Subject: [PATCH 14/22] Comment GetDefaultProject function --- cloudapi/api.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cloudapi/api.go b/cloudapi/api.go index a51f62bacf..292c631c76 100644 --- a/cloudapi/api.go +++ b/cloudapi/api.go @@ -368,6 +368,7 @@ func (c *Client) AccountMe() (*AccountMeResponse, error) { return &amr, nil } +// GetDefaultProject retrieves the default project for the given stack ID. func (c *Client) GetDefaultProject(stackID int64) (int64, string, error) { // TODO: remove this hardcoded URL req, err := c.NewRequest("GET", "https://api.k6.io/cloud/v6/projects", nil) From c619442639e1ca0bd4c142274b5b926d4a46b069 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 08:56:16 +0200 Subject: [PATCH 15/22] Remove nesting --- internal/cmd/cloud_login.go | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 3ffc67e271..1ed21da6d2 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -169,7 +169,8 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { for slug := range stacks { return slug, nil } - return "", errors.New("no stacks found for the provided token, please create a stack in Grafana Cloud and initialize GCk6 app") + return "", errors.New("no stacks found for the provided token " + + "please create a stack in Grafana Cloud and initialize GCk6 app") }(tokenValue) if err != nil { return fmt.Errorf("failed to get default stack: %w", err) @@ -223,20 +224,18 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return err } - if newCloudConf.Token.Valid { - valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) - printToStdout(c.globalState, fmt.Sprintf( - "\nLogged in successfully, token and stack info saved in %s\n", c.globalState.Flags.ConfigFilePath, - )) - if !c.globalState.Flags.Quiet { - printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) - - if newCloudConf.StackID.Valid { - printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.Int64))) - } - if newCloudConf.StackSlug.Valid { - printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) - } + valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) + printToStdout(c.globalState, fmt.Sprintf( + "\nLogged in successfully, token and stack info saved in %s\n", c.globalState.Flags.ConfigFilePath, + )) + if !c.globalState.Flags.Quiet { + printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) + + if newCloudConf.StackID.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.Int64))) + } + if newCloudConf.StackSlug.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) } } return nil From 44b495a7d8e40be2f9929ffd354951b0ad8b09dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 09:02:47 +0200 Subject: [PATCH 16/22] Remove breaking change --- internal/cmd/common.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/cmd/common.go b/internal/cmd/common.go index b8bf313846..6685ca1a5e 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -172,8 +172,10 @@ func resolveDefaultProjectID( } *stackID = null.IntFrom(id) } else { - gs.Logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") - return 0, fmt.Errorf("no projectID specified and no default stack set") + // TODO: do this in the future? But it is a breaking change... + // gs.Logger.Error("please specify a projectID in your test or use `k6 cloud login` to set up a default stack") + // return 0, fmt.Errorf("no projectID specified and no default stack set") + return 0, nil } } From 217e01176e472ecb610a42e2d03932eb8809ae31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 13:20:32 +0200 Subject: [PATCH 17/22] Make login code cleaner --- internal/cmd/cloud_login.go | 61 +++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index 1ed21da6d2..ee15bc456c 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -122,6 +122,12 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return nil case token.Valid: newCloudConf.Token = token + + err := validateToken(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String) + if err != nil { + return err + } + if stackSlug.Valid { normalizedSlug := stripGrafanaNetSuffix(stackSlug.String) newCloudConf.StackSlug = null.StringFrom(normalizedSlug) @@ -133,6 +139,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { } } default: + /* Token form */ tokenForm := ui.Form{ Banner: "Enter your token to authenticate with Grafana Cloud k6.\n" + "Please, consult the Grafana Cloud k6 documentation for instructions on how to generate one:\n" + @@ -161,29 +168,19 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return fmt.Errorf("token validation failed: %w", err) } - defaultStack, err := func(token string) (string, error) { - stacks, err := getStacks(c.globalState, currentJSONConfigRaw, token) - if err != nil { - return "", fmt.Errorf("failed to get default stack slug: %w", err) - } - for slug := range stacks { - return slug, nil - } - return "", errors.New("no stacks found for the provided token " + - "please create a stack in Grafana Cloud and initialize GCk6 app") - }(tokenValue) + /* Stack form */ + _, defaultStackSlug, err := getDefaultStack(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String) if err != nil { - return fmt.Errorf("failed to get default stack: %w", err) + return fmt.Errorf("failed to get default stack slug: %w", err) } - stackForm := ui.Form{ Banner: "\nEnter the stack where you want to run k6's commands by default.\n" + "Use the slug from your Grafana Cloud URL, e.g. my-team from https://my-team.grafana.net):", Fields: []ui.Field{ ui.StringField{ - Key: "StackSlug", + Key: "Stack", Label: "Stack", - Default: defaultStack, + Default: defaultStackSlug, }, }, } @@ -191,28 +188,15 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { if err != nil { return err } + newCloudConf.StackSlug = null.StringFrom(stripGrafanaNetSuffix(stackVals["Stack"])) - stackSlugInput := null.StringFrom(stackVals["StackSlug"]) - if !stackSlugInput.Valid { - return errors.New("stack cannot be empty") - } - normalizedSlug := stripGrafanaNetSuffix(stackSlugInput.String) - newCloudConf.StackSlug = null.StringFrom(normalizedSlug) - - id, err := resolveStackSlugToID(c.globalState, currentJSONConfigRaw, tokenValue, normalizedSlug) + id, err := resolveStackSlugToID(c.globalState, currentJSONConfigRaw, tokenValue, newCloudConf.StackSlug.String) if err != nil { return fmt.Errorf("could not resolve stack slug. Are you sure the slug is correct? %w", err) } newCloudConf.StackID = null.IntFrom(id) } - if newCloudConf.Token.Valid { - err := validateToken(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String) - if err != nil { - return err - } - } - if currentDiskConf.Collectors == nil { currentDiskConf.Collectors = make(map[string]json.RawMessage) } @@ -309,3 +293,20 @@ func getStacks(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) } return stacks, nil } + +func getDefaultStack(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) (int64, string, error) { + stacks, err := getStacks(gs, jsonRawConf, token) + if err != nil { + return 0, "", fmt.Errorf("failed to get default stack slug: %w", err) + } + + if len(stacks) == 0 { + return 0, "", errors.New("no stacks found for the provided token " + + "please create a stack in Grafana Cloud and initialize GCk6 app") + } + + for slug, id := range stacks { + return int64(id), slug, nil + } + return 0, "", nil // This should never happen +} From 35b401523255c9a968fb0ae9446493d88f728ae4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Fri, 18 Jul 2025 13:37:25 +0200 Subject: [PATCH 18/22] Make it work with stack tokens --- internal/cmd/cloud_login.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index ee15bc456c..f1ad934092 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "syscall" "github.com/fatih/color" @@ -297,6 +298,11 @@ func getStacks(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) func getDefaultStack(gs *state.GlobalState, jsonRawConf json.RawMessage, token string) (int64, string, error) { stacks, err := getStacks(gs, jsonRawConf, token) if err != nil { + // TODO: Improve handling of this error. This happens when the user uses a Stack tocken instead of a Personal token. + if strings.Contains(err.Error(), "Authentication failed") { + return 0, "", nil + } + return 0, "", fmt.Errorf("failed to get default stack slug: %w", err) } From 3f6df55f3373ee9c1684efec51b4d1e37130cde9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Tue, 22 Jul 2025 11:01:15 +0200 Subject: [PATCH 19/22] Keep token input as a StringField --- internal/cmd/cloud_login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index f1ad934092..a13788c070 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -146,7 +146,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { "Please, consult the Grafana Cloud k6 documentation for instructions on how to generate one:\n" + "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication", Fields: []ui.Field{ - ui.StringField{ + ui.PasswordField{ Key: "Token", Label: "Token", }, From 154b3ed4573a4c4e3a65b6ec4c61b00e0ae625cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Tue, 22 Jul 2025 11:11:09 +0200 Subject: [PATCH 20/22] Change warning text --- internal/cmd/common.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/common.go b/internal/cmd/common.go index 6685ca1a5e..f66a4893c7 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -185,6 +185,6 @@ func resolveDefaultProjectID( } *projectID = pid - gs.Logger.Warnf("Warning: no projectID specified, using default project of the stack: %s \n\n", stackSlug.String) + gs.Logger.Warnf("Warning: no projectID specified, using default project of the %s stack \n\n", stackSlug.String) return pid, nil } From e19b61fd5ae49f9a5f614b1f8194c94699b4d2f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Tue, 22 Jul 2025 11:22:25 +0200 Subject: [PATCH 21/22] Only display login message if token is valid --- internal/cmd/cloud_login.go | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index a13788c070..fa6420cf00 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -209,18 +209,20 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { return err } - valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) - printToStdout(c.globalState, fmt.Sprintf( - "\nLogged in successfully, token and stack info saved in %s\n", c.globalState.Flags.ConfigFilePath, - )) - if !c.globalState.Flags.Quiet { - printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) - - if newCloudConf.StackID.Valid { - printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.Int64))) - } - if newCloudConf.StackSlug.Valid { - printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) + if newCloudConf.Token.Valid { + valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) + printToStdout(c.globalState, fmt.Sprintf( + "\nLogged in successfully, token and stack info saved in %s\n", c.globalState.Flags.ConfigFilePath, + )) + if !c.globalState.Flags.Quiet { + printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) + + if newCloudConf.StackID.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(newCloudConf.StackID.Int64))) + } + if newCloudConf.StackSlug.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) + } } } return nil From 547f3563ea4977ccd94a551d8850f158ed80034a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Gonz=C3=A1lez=20Lopes?= Date: Tue, 22 Jul 2025 11:24:41 +0200 Subject: [PATCH 22/22] Improve error handling in getDefaultStack --- internal/cmd/cloud_login.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cmd/cloud_login.go b/internal/cmd/cloud_login.go index fa6420cf00..f428859ecd 100644 --- a/internal/cmd/cloud_login.go +++ b/internal/cmd/cloud_login.go @@ -316,5 +316,8 @@ func getDefaultStack(gs *state.GlobalState, jsonRawConf json.RawMessage, token s for slug, id := range stacks { return int64(id), slug, nil } - return 0, "", nil // This should never happen + + // This should never happen + return 0, "", errors.New("no stacks found for the provided token " + + "please create a stack in Grafana Cloud and initialize GCk6 app") }