-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Enforce stack info for cloud commands #5833
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
codebien
wants to merge
16
commits into
master
Choose a base branch
from
5651-make-stack-required
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
16 commits
Select commit
Hold shift + click to select a range
81d1f60
Enforce stack in k6 cloud run command
codebien f1a76b5
Enforce stack in cloud --local-execution command
codebien 28cb3ca
Use stdout assertion in missing-stack test for consistency
codebien 02b4ae4
Require stack in k6 cloud login, remove v1 token validation fallback
codebien 8401eb2
Remove dead v1 validate-token handler from mock server
codebien 069bc1e
Stack is now a mandatory field
codebien 370eba2
Partially mask the returned token
codebien 7b831d2
Improved the visualization's UX
codebien 15bc60d
Allow login by flags only if they provide both
codebien 0a4f7ec
Adjusted the tests for the new logic
codebien d864201
Refined the unautheticated error on cloud run
codebien 473f762
Migrated a few additional tests
codebien 0b604df
Refactor to reduce the Cognitive complexity
codebien 335f923
Addressed linter issues
codebien 78d3cfe
Refined the token mask logic
codebien 36b0253
Reduce again the cognitive complexity after rebase
codebien File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -4,6 +4,7 @@ import ( | |||||
| "encoding/json" | ||||||
| "errors" | ||||||
| "fmt" | ||||||
| "strconv" | ||||||
| "strings" | ||||||
| "syscall" | ||||||
|
|
||||||
|
|
@@ -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> | ||||||
|
|
||||||
|
|
@@ -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 | ||||||
|
|
@@ -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 { | ||||||
|
|
@@ -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 | ||||||
| } | ||||||
|
|
@@ -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) | ||||||
| } | ||||||
|
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 | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Tiny nit.
Suggested change
|
||||||
| // 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 { | ||||||
|
|
@@ -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 | ||||||
| } | ||||||
|
|
||||||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 sameerrUserUnauthenticated, 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.