Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions internal/cmd/cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,22 @@ import (
// Grafana Cloud without being logged in or having a valid token.
//
//nolint:staticcheck // the error is shown to the user so here punctuation and capital are required
var errUserUnauthenticated = errors.New("To run tests in Grafana Cloud, you must first authenticate." +
" Run the `k6 cloud login` command, or check the docs" +
var errUserUnauthenticated = errors.New("You must first authenticate to run tests in Grafana Cloud." +
" Run the `k6 cloud login` command providing the stack and token, or check the docs" +
" https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication" +
" for additional authentication methods.")
" for additional methods.")

// checkCloudLogin verifies that both a token and a stack are configured.
// Together they represent a complete Grafana Cloud login.
func checkCloudLogin(conf cloudapi.Config) error {
if !conf.Token.Valid || conf.Token.String == "" {
return errUserUnauthenticated
}
if !conf.StackID.Valid || conf.StackID.Int64 == 0 {
return errUserUnauthenticated
}
return nil
}
Comment on lines +34 to +49
Copy link
Copy Markdown
Contributor

@inancgumus inancgumus Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checkCloudLogin() collapses “missing token” and “missing stack” into the same errUserUnauthenticated, so users with only a token get only a generic authentication message. Would be nicer to return distinct errors, or at least include the specific missing piece in the message.


// cmdCloud handles the `k6 cloud` sub-command
type cmdCloud struct {
Expand Down Expand Up @@ -130,8 +142,8 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
if !cloudConfig.Token.Valid {
return errUserUnauthenticated
if err := checkCloudLogin(cloudConfig); err != nil {
return err
}

// Display config warning if needed
Expand Down Expand Up @@ -481,14 +493,5 @@ func resolveAndSetProjectID(

cloudConfig.ProjectID = null.IntFrom(projectID)
}
if !cloudConfig.StackID.Valid || cloudConfig.StackID.Int64 == 0 {
fallBackMsg := ""
if !cloudConfig.ProjectID.Valid || cloudConfig.ProjectID.Int64 == 0 {
fallBackMsg = "Falling back to the first available stack. "
}
gs.Logger.Warn("DEPRECATED: No stack specified. " + fallBackMsg +
"Consider setting a default stack via the `k6 cloud login` command or the `K6_CLOUD_STACK_ID` " +
"environment variable as this will become mandatory in the next major release.")
}
return nil
}
217 changes: 111 additions & 106 deletions internal/cmd/cloud_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"syscall"

Expand Down Expand Up @@ -35,9 +36,6 @@ func getCmdCloudLogin(gs *state.GlobalState) *cobra.Command {
# Authenticate interactively with Grafana Cloud
$ {{.}} cloud login

# Store a token in k6's persistent configuration
$ {{.}} cloud login -t <YOUR_TOKEN>

# Store a token in k6's persistent configuration and set the stack
$ {{.}} cloud login -t <YOUR_TOKEN> --stack <YOUR_STACK_URL_OR_SLUG>

Expand Down Expand Up @@ -68,6 +66,8 @@ func getCmdCloudLogin(gs *state.GlobalState) *cobra.Command {
//
//nolint:funlen
func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error {
printBanner(c.globalState)

currentDiskConf, err := readDiskConfig(c.globalState)
if err != nil {
return err
Expand All @@ -88,7 +88,7 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error {

show := getNullBool(cmd.Flags(), "show")
reset := getNullBool(cmd.Flags(), "reset")
token := getNullString(cmd.Flags(), "token")
tokenInput := getNullString(cmd.Flags(), "token")
stackInput := getNullString(cmd.Flags(), "stack")

switch {
Expand All @@ -97,61 +97,31 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error {
newCloudConf.StackID = null.IntFromPtr(nil)
newCloudConf.StackURL = null.StringFromPtr(nil)
newCloudConf.DefaultProjectID = null.IntFromPtr(nil)
printToStdout(c.globalState, " token and stack info reset\n")
printToStdout(c.globalState, "\nToken and stack info have been reset.\n")
case show.Bool:
printConfig(c.globalState, newCloudConf)
return nil
case token.Valid:
err := validateInputs(c.globalState, &newCloudConf, currentJSONConfigRaw, token, stackInput)
if err != nil {
return err
}
default:
gs := c.globalState

/* Token form */
tokenForm := ui.Form{
Banner: "Enter your token to authenticate with Grafana Cloud.\n" +
"Please, consult the 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{
Key: "Token",
Label: "Token",
},
},
case tokenInput.Valid || stackInput.Valid:
if !stackInput.Valid || stackInput.String == "" {
return errors.New("stack value is required but it was not passed or is empty")
}
if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert
gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input")
if !tokenInput.Valid || tokenInput.String == "" {
return errors.New("token value is required but it was not passed or is empty")
}
tokenVals, err := tokenForm.Run(gs.Stdin, gs.Stdout)
err := authenticateUserToken(c.globalState, &newCloudConf, currentJSONConfigRaw, tokenInput.String, stackInput.String)
if err != nil {
return err
}
tokenInput := null.StringFrom(tokenVals["Token"])
if !tokenInput.Valid {
return errors.New("token cannot be empty")
}
default:
gs := c.globalState

/* Stack form */
stackForm := ui.Form{
Banner: "\nEnter the stack where you want to run k6's commands by default.\n" +
"You can enter a full URL (e.g. https://my-team.grafana.net) or just the slug (e.g. my-team):",
Fields: []ui.Field{
ui.StringField{
Key: "Stack",
Label: "Stack",
Default: "None",
},
},
}
stackVals, err := stackForm.Run(gs.Stdin, gs.Stdout)
userinfo, err := promptUserAuthForm(gs)
if err != nil {
return err
}
stackInput := null.StringFrom(strings.TrimSpace(stackVals["Stack"]))

err = validateInputs(gs, &newCloudConf, currentJSONConfigRaw, tokenInput, stackInput)
err = authenticateUserToken(gs, &newCloudConf, currentJSONConfigRaw,
userinfo.token, userinfo.stack)
if err != nil {
return err
}
Expand Down Expand Up @@ -182,39 +152,108 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error {
return nil
}

func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) {
valueColor := getColor(gs.Flags.NoColor || !gs.Stdout.IsTTY, color.FgCyan)
printToStdout(gs, fmt.Sprintf(" token: %s\n", valueColor.Sprint(cloudConf.Token.String)))
type userAuthForm struct {
stack string
token string
}

if !cloudConf.StackID.Valid && !cloudConf.StackURL.Valid {
printToStdout(gs, " stack-id: <not set>\n")
printToStdout(gs, " stack-url: <not set>\n")
printToStdout(gs, " default-project-id: <not set>\n")
func promptUserAuthForm(gs *state.GlobalState) (userAuthForm, error) {
/* Token form */
tokenForm := ui.Form{
Banner: "Enter your token to authenticate with Grafana Cloud.\n" +
"Please, consult the 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{
Key: "Token",
Label: "Token",
},
},
}
if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert
gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input")
}
tokenVals, err := tokenForm.Run(gs.Stdin, gs.Stdout)
if err != nil {
return userAuthForm{}, err
}
token := strings.TrimSpace(tokenVals["Token"])
if token == "" {
return userAuthForm{}, errors.New("token cannot be empty")
}

return
/* Stack form */
stackForm := ui.Form{
Banner: "\nEnter the stack where you want to run k6's commands by default.\n" +
"You can enter a full URL (e.g. https://my-team.grafana.net) or just the slug (e.g. my-team):",
Fields: []ui.Field{
ui.StringField{
Key: "Stack",
Label: "Stack",
},
},
}
stackVals, err := stackForm.Run(gs.Stdin, gs.Stdout)
if err != nil {
return userAuthForm{}, err
}
stack := strings.TrimSpace(stackVals["Stack"])
if stack == "" {
return userAuthForm{}, errors.New("stack cannot be empty")
}

return userAuthForm{token: token, stack: stack}, nil
}

func printConfig(gs *state.GlobalState, cloudConf cloudapi.Config) {
const notSet = "<not set>"
token, stackID, stackURL, defProj := notSet, notSet, notSet, notSet

if cloudConf.Token.String != "" {
token = maskToken(cloudConf.Token.String)
}
Comment thread
codebien marked this conversation as resolved.
if cloudConf.StackID.Valid {
printToStdout(gs, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(cloudConf.StackID.Int64)))
stackID = strconv.FormatInt(cloudConf.StackID.Int64, 10)
}
if cloudConf.StackURL.Valid {
printToStdout(gs, fmt.Sprintf(" stack-url: %s\n", valueColor.Sprint(cloudConf.StackURL.String)))
stackURL = cloudConf.StackURL.String
}
if cloudConf.DefaultProjectID.Valid {
printToStdout(gs, fmt.Sprintf(" default-project-id: %s\n",
valueColor.Sprint(cloudConf.DefaultProjectID.Int64)))
defProj = strconv.FormatInt(cloudConf.DefaultProjectID.Int64, 10)
}

valueColor := getColor(gs.Flags.NoColor || !gs.Stdout.IsTTY, color.FgCyan)
printToStdout(gs, fmt.Sprintf(" token: %s\n", valueColor.Sprint(token)))
printToStdout(gs, fmt.Sprintf(" stack-id: %s\n", valueColor.Sprint(stackID)))
printToStdout(gs, fmt.Sprintf(" stack-url: %s\n", valueColor.Sprint(stackURL)))
printToStdout(gs, fmt.Sprintf(" default-project-id: %s\n", valueColor.Sprint(defProj)))
}

func maskToken(unmasked string) string {
if len(unmasked) < 1 {
return ""
}
// Require at least 4 asterisks in the middle to give a meaningful visual hint.
// Any token shorter than 12 chars would produce fewer, so mask it entirely.
if len(unmasked) < 12 {
return strings.Repeat("*", len(unmasked))
}
// We try to have a good DX here.
// A valid Cloud token should be 12+ chars, so it prints the token with all
// the chars masked, except the first and the last four.
asterisks := strings.Repeat("*", len(unmasked)-8)
return unmasked[:4] + asterisks + unmasked[len(unmasked)-4:]
}

// validateInputs validates a token and a stack if provided
// tokenAuthentication validates a token and a stack
Copy link
Copy Markdown
Contributor

@inancgumus inancgumus Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny nit.

Suggested change
// tokenAuthentication validates a token and a stack
// authenticateUserToken validates a token and a stack

// and update the config with the given inputs
func validateInputs(
func authenticateUserToken(
gs *state.GlobalState,
config *cloudapi.Config,
rawConfig json.RawMessage,
token, stackInput null.String,
token, stack string,
) error {
config.Token = token
config.Token = null.StringFrom(token)
consolidatedCurrentConfig, warn, err := cloudapi.GetConsolidatedConfig(
rawConfig, gs.Env, "", nil)
if err != nil {
Expand All @@ -224,53 +263,19 @@ func validateInputs(
gs.Logger.Warn(warn)
}

stackValue := stackInput.String
if stackInput.Valid && stackValue != "" && stackValue != "None" {
stackURL, stackID, defaultProjectID, err := validateTokenV6(
gs, consolidatedCurrentConfig, token.String, stackValue)
if err != nil {
return fmt.Errorf(
"your stack is invalid - please, consult the documentation "+
"for instructions on how to get yours: "+
"https://grafana.com/docs/grafana-cloud/testing/k6/author-run/configure-stack. "+
"Error details: %w",
err)
}
config.StackURL = null.StringFrom(stackURL)
config.StackID = null.IntFrom(stackID)
config.DefaultProjectID = null.IntFrom(defaultProjectID)
} else {
err = validateTokenV1(gs, consolidatedCurrentConfig, config.Token.String)
if err != nil {
return err
}
}

return nil
}

// validateTokenV1 validates a token using v1 cloud API.
//
// Deprecated: use validateTokenV6 instead if a stack name is provided.
func validateTokenV1(gs *state.GlobalState, config cloudapi.Config, token string) error {
client := cloudapi.NewClient(
gs.Logger,
token,
config.Host.String,
build.Version,
config.Timeout.TimeDuration(),
)

res, err := client.ValidateToken()
stackURL, stackID, defaultProjectID, err := validateTokenV6(
gs, consolidatedCurrentConfig, token, stack)
if err != nil {
return fmt.Errorf("can't validate the API token: %s", err.Error())
}
return fmt.Errorf( //nolint:staticcheck // ST1005: this is a user-facing error
"Authentication failed as provided token or stack might not be valid."+
" Learn more: https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication."+
" Server error for details: %w",

if !res.IsValid {
return errors.New("your API token is invalid - " +
"please, consult the documentation for instructions on how to generate a new one:\n" +
"https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication")
err)
}
config.StackURL = null.StringFrom(stackURL)
config.StackID = null.IntFrom(stackID)
config.DefaultProjectID = null.IntFrom(defaultProjectID)

return nil
}
Expand Down
Loading
Loading