diff --git a/cloudapi/api.go b/cloudapi/api.go index 034fa1279a..292c631c76 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" @@ -336,3 +338,95 @@ func (c *Client) ValidateToken() (*ValidateTokenResponse, error) { return &vtr, nil } + +// 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"` +} + +// 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) { + // TODO: remove this hardcoded URL + req, err := c.NewRequest("GET", "https://api.k6.io/v3/account/me", nil) + if err != nil { + return nil, err + } + + amr := AccountMeResponse{} + err = c.Do(req, &amr) + if err != nil { + return nil, err + } + + 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) + 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", 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") + + // 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 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) + 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", stackID) + } + + 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", stackID) +} diff --git a/cloudapi/config.go b/cloudapi/config.go index 0dd796c73c..3659c82626 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.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"` Name null.String `json:"name" envconfig:"K6_CLOUD_NAME"` @@ -103,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 } @@ -240,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 } @@ -250,6 +259,12 @@ func mergeFromCloudOptionAndExternal( if tmpConfig.Token.Valid { conf.Token = tmpConfig.Token } + if tmpConfig.StackID.Valid { + conf.StackID = tmpConfig.StackID + } + if tmpConfig.StackSlug.Valid { + conf.StackSlug = tmpConfig.StackSlug + } 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..60937b07a9 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,34 @@ 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 := resolveDefaultProjectID( + c.gs, + client, + test.derivedConfig.Collectors["cloud"], + cloudConfig.Token.String, + cloudConfig.StackSlug, + &cloudConfig.StackID, + &cloudConfig.ProjectID.Int64, + ) + if err != nil { + return 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 71783cb3a6..f428859ecd 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" @@ -30,16 +31,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{ @@ -58,17 +62,22 @@ 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("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-slug", "", "specify the stack where commands will run by default") return loginCloudCommand } // 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 { - 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) @@ -92,18 +101,47 @@ func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { show := getNullBool(cmd.Flags(), "show") reset := getNullBool(cmd.Flags(), "reset") token := getNullString(cmd.Flags(), "token") + stackSlug := getNullString(cmd.Flags(), "stack-slug") + switch { case reset.Valid: newCloudConf.Token = null.StringFromPtr(nil) - printToStdout(c.globalState, " token reset\n") + newCloudConf.StackID = null.IntFromPtr(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 && !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.Int64))) + printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) + } 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) + id, err := resolveStackSlugToID(c.globalState, currentJSONConfigRaw, token.String, normalizedSlug) + if err == nil { + newCloudConf.StackID = null.IntFrom(id) + } else { + return fmt.Errorf("could not resolve stack slug. Are you sure the slug is correct? %w", err) + } + } default: - form := ui.Form{ + /* 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" + "https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication", @@ -117,19 +155,47 @@ 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 { - err := validateToken(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String) + 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) + } + + /* Stack form */ + _, defaultStackSlug, err := getDefaultStack(c.globalState, currentJSONConfigRaw, newCloudConf.Token.String) + if err != nil { + 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: "Stack", + Label: "Stack", + Default: defaultStackSlug, + }, + }, + } + stackVals, err := stackForm.Run(c.globalState.Stdin, c.globalState.Stdout) if err != nil { return err } + newCloudConf.StackSlug = null.StringFrom(stripGrafanaNetSuffix(stackVals["Stack"])) + + 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 currentDiskConf.Collectors == nil { @@ -144,9 +210,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.Int64))) + } + if newCloudConf.StackSlug.Valid { + printToStdout(c.globalState, fmt.Sprintf(" stack-slug: %s\n", valueColor.Sprint(newCloudConf.StackSlug.String))) + } + } } return nil } @@ -186,3 +263,61 @@ 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 { + stackName := stripGrafanaNetSuffix(organization.GrafanaStackName) + stacks[stackName] = organization.GrafanaStackID + } + 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 { + // 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) + } + + 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 + } + + // 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") +} diff --git a/internal/cmd/common.go b/internal/cmd/common.go index 896db9b9f2..f66a4893c7 100644 --- a/internal/cmd/common.go +++ b/internal/cmd/common.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "fmt" "os" "syscall" @@ -11,6 +12,7 @@ import ( "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 +129,62 @@ 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( + 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 { + // 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 + } + } + + 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 + + gs.Logger.Warnf("Warning: no projectID specified, using default project of the %s stack \n\n", stackSlug.String) + return pid, nil +} 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 +} diff --git a/internal/cmd/outputs_cloud.go b/internal/cmd/outputs_cloud.go index 2d0eac0387..a630314dde 100644 --- a/internal/cmd/outputs_cloud.go +++ b/internal/cmd/outputs_cloud.go @@ -118,6 +118,23 @@ 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 := resolveDefaultProjectID( + gs, + apiClient, + test.derivedConfig.Collectors["cloud"], + conf.Token.String, + conf.StackSlug, + &conf.StackID, + &testRun.ProjectID, + ) + if err != nil { + return err + } + + testRun.ProjectID = projectID + } + response, err := apiClient.CreateTestRun(testRun) if err != nil { return err