From aab3bc8523a4e4b1edbc0f12ed21aac9511dbc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Crevon?= Date: Wed, 24 Jul 2024 12:53:31 +0200 Subject: [PATCH] Move cloud run and login functionalities under cloud subcommands (#3813) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduce a `k6 cloud run` command Important notice: this commit declare a cobra sub-command holding the logic for the `k6 cloud run` sub-command, but does not register it. In this commit, we duplicate the logic from the existing `k6 cloud` logic, with very little adjustments, to support the later registry of the `k6 cloud run` command. To simplify the collaboration on this and further reviews, we delegate any refactoring of the original cloud command's logic, to a further commit or Pull Request. * Introduce a `k6 cloud login` command Important notice: this commit declare a cobra sub-command holding the logic for the `k6 cloud login` sub-command, but does not register it. In this commit, we duplicate the logic from the existing `k6 login` logic, with very little adjustments, to support the later registry of the `k6 cloud login` command. To simplify the collaboration on this and further reviews, we delegate any refactoring of the original cloud command's logic, to a further commit or Pull Request. This new `k6 cloud login` command is notably focusing solely on authenticating with the Grafana Cloud k6, and by design does not aim to support InfluxDB authentication. * Register run and login subcommands of the cloud command * Add deprecation warning to k6 login and k6 login cloud commands * FIXME add tests assert k6 cloud run command's arguments handling * Apply Pull-Request suggestions Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> * Improve cloud commands missing arguments handling * Apply suggestions from code review * Refactor cloud run command and tests to leverage existing code * Apply suggestions from code review Co-authored-by: Oleg Bespalov * fix lint of cmd package --------- Co-authored-by: Joan López de la Franca Beltran <5459617+joanlopez@users.noreply.github.com> Co-authored-by: Oleg Bespalov --- cmd/cloud.go | 72 +++++- cmd/cloud_login.go | 179 ++++++++++++++ cmd/cloud_run.go | 49 ++++ cmd/login.go | 2 + cmd/login_cloud.go | 5 +- cmd/tests/cmd_cloud_run_test.go | 12 + cmd/tests/cmd_cloud_test.go | 424 ++++++++++++++++---------------- 7 files changed, 526 insertions(+), 217 deletions(-) create mode 100644 cmd/cloud_login.go create mode 100644 cmd/cloud_run.go create mode 100644 cmd/tests/cmd_cloud_run_test.go diff --git a/cmd/cloud.go b/cmd/cloud.go index 53b7139d76d..9f529bd7064 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -8,20 +8,22 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "time" "github.com/fatih/color" - "github.com/spf13/cobra" - "github.com/spf13/pflag" - "go.k6.io/k6/cloudapi" - "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib" "go.k6.io/k6/lib/consts" "go.k6.io/k6/ui/pb" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + + "go.k6.io/k6/cmd/state" ) // cmdCloud handles the `k6 cloud` sub-command @@ -117,7 +119,10 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { return err } if !cloudConfig.Token.Valid { - return errors.New("Not logged in, please use `k6 login cloud`.") //nolint:golint,revive,stylecheck + return errors.New( //nolint:golint + "not logged in, please login first to the Grafana Cloud k6 " + + "using the \"k6 cloud login\" command", + ) } // Display config warning if needed @@ -343,20 +348,67 @@ func getCmdCloud(gs *state.GlobalState) *cobra.Command { } exampleText := getExampleText(gs, ` - {{.}} cloud script.js`[1:]) + # [deprecated] Run a k6 script in the Grafana Cloud k6 + $ {{.}} cloud script.js + + # [deprecated] Run a k6 archive in the Grafana Cloud k6 + $ {{.}} cloud archive.tar + + # Authenticate with Grafana Cloud k6 + $ {{.}} cloud login + + # Run a k6 script in the Grafana Cloud k6 + $ {{.}} cloud run script.js + + # Run a k6 archive in the Grafana Cloud k6 + $ {{.}} cloud run archive.tar`[1:]) cloudCmd := &cobra.Command{ Use: "cloud", Short: "Run a test on the cloud", - Long: `Run a test on the cloud. + Long: `Run a test in the Grafana Cloud k6. -This will execute the test on the k6 cloud service. Use "k6 login cloud" to authenticate.`, - Example: exampleText, - Args: exactArgsWithMsg(1, "arg should either be \"-\", if reading script from stdin, or a path to a script file"), +This will archive test script(s), including all necessary resources, and execute the test in the Grafana Cloud k6 +service. Be sure to run the "k6 cloud login" command prior to authenticate with Grafana Cloud k6.`, + Args: exactCloudArgs(), + Deprecated: `the k6 team is in the process of modifying and deprecating the "k6 cloud" command behavior. +In the future, the "cloud" command will only display a help text, instead of running tests in the Grafana Cloud k6. + +To run tests in the cloud, users are now invited to migrate to the "k6 cloud run" command instead. +`, PreRunE: c.preRun, RunE: c.run, + Example: exampleText, } + + // Register `k6 cloud` subcommands + cloudCmd.AddCommand(getCmdCloudRun(gs)) + cloudCmd.AddCommand(getCmdCloudLogin(gs)) + cloudCmd.Flags().SortFlags = false cloudCmd.Flags().AddFlagSet(c.flagSet()) + return cloudCmd } + +func exactCloudArgs() cobra.PositionalArgs { + return func(_ *cobra.Command, args []string) error { + const baseErrMsg = `the "k6 cloud" command expects either a subcommand such as "run" or "login", or ` + + "a single argument consisting in a path to a script/archive, or the `-` symbol instructing " + + "the command to read the test content from stdin" + + if len(args) == 0 { + return fmt.Errorf(baseErrMsg + "; " + "received no arguments") + } + + hasSubcommand := args[0] == "run" || args[0] == "login" + if len(args) > 1 && !hasSubcommand { + return fmt.Errorf( + baseErrMsg+"; "+"received %d arguments %q, and %s is not a valid subcommand", + len(args), strings.Join(args, " "), args[0], + ) + } + + return nil + } +} diff --git a/cmd/cloud_login.go b/cmd/cloud_login.go new file mode 100644 index 00000000000..0224839935a --- /dev/null +++ b/cmd/cloud_login.go @@ -0,0 +1,179 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "syscall" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "golang.org/x/term" + "gopkg.in/guregu/null.v3" + + "go.k6.io/k6/cloudapi" + "go.k6.io/k6/cmd/state" + "go.k6.io/k6/lib/consts" + "go.k6.io/k6/ui" +) + +const cloudLoginCommandName = "login" + +type cmdCloudLogin struct { + globalState *state.GlobalState +} + +func getCmdCloudLogin(gs *state.GlobalState) *cobra.Command { + c := &cmdCloudLogin{ + globalState: gs, + } + + // loginCloudCommand represents the 'cloud login' command + exampleText := getExampleText(gs, ` + # Log in with an email/password + {{.}} cloud login + + # Store a token in k6's persistent configuration + {{.}} cloud login -t + + # Display the stored token + {{.}} cloud login -s + + # Reset the stored token + {{.}} cloud login -r`[1:]) + + loginCloudCommand := &cobra.Command{ + Use: cloudLoginCommandName, + Short: "Authenticate with Grafana Cloud k6", + Long: `Authenticate with Grafana Cloud k6. + +This command will authenticate you with Grafana Cloud k6. +Once authenticated you can start running tests in the cloud by using the "k6 cloud run" +command, or by executing a test locally and outputting samples to the cloud using +the "k6 run -o cloud" command. +`, + Example: exampleText, + Args: cobra.NoArgs, + RunE: c.run, + } + + 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") + + return loginCloudCommand +} + +// run is the code that runs when the user executes `k6 cloud login` +// +//nolint:funlen +func (c *cmdCloudLogin) run(cmd *cobra.Command, _ []string) error { + currentDiskConf, err := readDiskConfig(c.globalState) + if err != nil { + return err + } + + currentJSONConfig := cloudapi.Config{} + currentJSONConfigRaw := currentDiskConf.Collectors["cloud"] + if currentJSONConfigRaw != nil { + // We only want to modify this config, see comment below + if jsonerr := json.Unmarshal(currentJSONConfigRaw, ¤tJSONConfig); jsonerr != nil { + return jsonerr + } + } + + // 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( + currentJSONConfigRaw, c.globalState.Env, "", nil, nil) + if err != nil { + return err + } + + if warn != "" { + c.globalState.Logger.Warn(warn) + } + + // But we don't want to save them back to the JSON file, we only + // want to save what already existed there and the login details. + newCloudConf := currentJSONConfig + + show := getNullBool(cmd.Flags(), "show") + reset := getNullBool(cmd.Flags(), "reset") + token := getNullString(cmd.Flags(), "token") + switch { + case reset.Valid: + newCloudConf.Token = null.StringFromPtr(nil) + printToStdout(c.globalState, " token reset\n") + case show.Bool: + case token.Valid: + newCloudConf.Token = token + default: + form := ui.Form{ + Banner: "Please enter your Grafana Cloud k6 credentials", + Fields: []ui.Field{ + ui.StringField{ + Key: "Email", + Label: "Email", + }, + ui.PasswordField{ + Key: "Password", + Label: "Password", + }, + }, + } + 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) + if err != nil { + return err + } + email := vals["Email"] + password := vals["Password"] + + client := cloudapi.NewClient( + c.globalState.Logger, + "", + consolidatedCurrentConfig.Host.String, + consts.Version, + consolidatedCurrentConfig.Timeout.TimeDuration()) + + var res *cloudapi.LoginResponse + res, err = client.Login(email, password) + if err != nil { + return err + } + + if res.Token == "" { + return errors.New("your account does not appear to have an active API token, please consult the " + + "Grafana Cloud k6 documentation for instructions on how to generate " + + "one: https://grafana.com/docs/grafana-cloud/testing/k6/author-run/tokens-and-cli-authentication") + } + + newCloudConf.Token = null.StringFrom(res.Token) + } + + if currentDiskConf.Collectors == nil { + currentDiskConf.Collectors = make(map[string]json.RawMessage) + } + currentDiskConf.Collectors["cloud"], err = json.Marshal(newCloudConf) + if err != nil { + return err + } + if err := writeDiskConfig(c.globalState, currentDiskConf); err != nil { + return err + } + + if newCloudConf.Token.Valid { + valueColor := getColor(c.globalState.Flags.NoColor || !c.globalState.Stdout.IsTTY, color.FgCyan) + if !c.globalState.Flags.Quiet { + printToStdout(c.globalState, fmt.Sprintf(" token: %s\n", valueColor.Sprint(newCloudConf.Token.String))) + } + printToStdout(c.globalState, fmt.Sprintf( + "Logged in successfully, token saved in %s\n", c.globalState.Flags.ConfigFilePath, + )) + } + return nil +} diff --git a/cmd/cloud_run.go b/cmd/cloud_run.go new file mode 100644 index 00000000000..2dd81033e27 --- /dev/null +++ b/cmd/cloud_run.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "github.com/spf13/cobra" + "go.k6.io/k6/cmd/state" +) + +const cloudRunCommandName string = "run" + +func getCmdCloudRun(gs *state.GlobalState) *cobra.Command { + deprecatedCloudCmd := &cmdCloud{ + gs: gs, + showCloudLogs: true, + exitOnRunning: false, + uploadOnly: false, + } + + exampleText := getExampleText(gs, ` + # Run a test script in Grafana Cloud k6 + $ {{.}} cloud run script.js + + # Run a test archive in Grafana Cloud k6 + $ {{.}} cloud run archive.tar + + # Read a test script or archive from stdin and run it in Grafana Cloud k6 + $ {{.}} cloud run - < script.js`[1:]) + + cloudRunCmd := &cobra.Command{ + Use: cloudRunCommandName, + Short: "Run a test in Grafana Cloud k6", + Long: `Run a test in Grafana Cloud k6. + +This will archive test script(s), including all necessary resources, and execute the test in the Grafana Cloud k6 +service. Using this command requires to be authenticated against Grafana Cloud k6. +Use the "k6 cloud login" command to authenticate.`, + Example: exampleText, + Args: exactArgsWithMsg(1, + "the k6 cloud run command expects a single argument consisting in either a path to a script or "+ + "archive file, or the \"-\" symbol indicating the script or archive should be read from stdin", + ), + PreRunE: deprecatedCloudCmd.preRun, + RunE: deprecatedCloudCmd.run, + } + + cloudRunCmd.Flags().SortFlags = false + cloudRunCmd.Flags().AddFlagSet(deprecatedCloudCmd.flagSet()) + + return cloudRunCmd +} diff --git a/cmd/login.go b/cmd/login.go index 8965998ef40..d0b1c43301d 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -16,6 +16,8 @@ func getCmdLogin(gs *state.GlobalState) *cobra.Command { Logging into a service changes the default when just "-o [type]" is passed with no parameters, you can always override the stored credentials by passing some on the commandline.`, + Deprecated: `and will be removed in a future release. Please use the "k6 cloud login" command instead. +`, RunE: func(cmd *cobra.Command, _ []string) error { return cmd.Usage() }, diff --git a/cmd/login_cloud.go b/cmd/login_cloud.go index b1438f3371c..0aadf7fb749 100644 --- a/cmd/login_cloud.go +++ b/cmd/login_cloud.go @@ -33,11 +33,14 @@ func getCmdLoginCloud(gs *state.GlobalState) *cobra.Command { loginCloudCommand := &cobra.Command{ Use: "cloud", Short: "Authenticate with k6 Cloud", - Long: `Authenticate with k6 Cloud", + Long: `Authenticate with Grafana Cloud k6. This will set the default token used when just "k6 run -o cloud" is passed.`, Example: exampleText, Args: cobra.NoArgs, + Deprecated: `and will be removed in a future release. +Please use the "k6 cloud login" command instead. +`, RunE: func(cmd *cobra.Command, _ []string) error { currentDiskConf, err := readDiskConfig(gs) if err != nil { diff --git a/cmd/tests/cmd_cloud_run_test.go b/cmd/tests/cmd_cloud_run_test.go new file mode 100644 index 00000000000..b16ec5b1a1c --- /dev/null +++ b/cmd/tests/cmd_cloud_run_test.go @@ -0,0 +1,12 @@ +package tests + +import "testing" + +func TestK6CloudRun(t *testing.T) { + t.Parallel() + runCloudTests(t, setupK6CloudRunCmd) +} + +func setupK6CloudRunCmd(cliFlags []string) []string { + return append([]string{"k6", "cloud", "run"}, append(cliFlags, "test.js")...) +} diff --git a/cmd/tests/cmd_cloud_test.go b/cmd/tests/cmd_cloud_test.go index a8e76e38006..838cb60a461 100644 --- a/cmd/tests/cmd_cloud_test.go +++ b/cmd/tests/cmd_cloud_test.go @@ -10,97 +10,45 @@ import ( "path/filepath" "testing" + "go.k6.io/k6/lib/testutils" + + "go.k6.io/k6/cmd" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.k6.io/k6/cloudapi" - "go.k6.io/k6/cmd" "go.k6.io/k6/lib/fsext" - "go.k6.io/k6/lib/testutils" ) -func cloudTestStartSimple(tb testing.TB, testRunID int) http.Handler { - return http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) { - resp.WriteHeader(http.StatusOK) - _, err := fmt.Fprintf(resp, `{"reference_id": "%d"}`, testRunID) - assert.NoError(tb, err) - }) +func TestK6Cloud(t *testing.T) { + t.Parallel() + runCloudTests(t, setupK6CloudCmd) } -func getMockCloud( - t *testing.T, testRunID int, - archiveUpload http.Handler, progressCallback func() cloudapi.TestProgressResponse, -) *httptest.Server { - if archiveUpload == nil { - archiveUpload = cloudTestStartSimple(t, testRunID) - } - testProgressURL := fmt.Sprintf("GET ^/v1/test-progress/%d$", testRunID) - defaultProgress := cloudapi.TestProgressResponse{ - RunStatusText: "Finished", - RunStatus: cloudapi.RunStatusFinished, - ResultStatus: cloudapi.ResultStatusPassed, - Progress: 1, - } - - srv := getTestServer(t, map[string]http.Handler{ - "POST ^/v1/archive-upload$": archiveUpload, - testProgressURL: http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) { - testProgress := defaultProgress - if progressCallback != nil { - testProgress = progressCallback() - } - respBody, err := json.Marshal(testProgress) - assert.NoError(t, err) - _, err = fmt.Fprint(resp, string(respBody)) - assert.NoError(t, err) - }), - }) - - t.Cleanup(srv.Close) - - return srv +func setupK6CloudCmd(cliFlags []string) []string { + return append([]string{"k6", "cloud"}, append(cliFlags, "test.js")...) } -func getSimpleCloudTestState( - t *testing.T, script []byte, cliFlags []string, - archiveUpload http.Handler, progressCallback func() cloudapi.TestProgressResponse, -) *GlobalTestState { - if script == nil { - script = []byte(`export default function() {}`) - } - - if cliFlags == nil { - cliFlags = []string{"--verbose", "--log-output=stdout"} - } - - srv := getMockCloud(t, 123, archiveUpload, progressCallback) - - ts := NewGlobalTestState(t) - require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), script, 0o644)) - ts.CmdArgs = append(append([]string{"k6", "cloud"}, cliFlags...), "test.js") - ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet - ts.Env["K6_CLOUD_HOST"] = srv.URL - ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud - - return ts -} +type setupCommandFunc func(cliFlags []string) []string -func TestCloudNotLoggedIn(t *testing.T) { - t.Parallel() +func runCloudTests(t *testing.T, setupCmd setupCommandFunc) { + t.Run("TestCloudNotLoggedIn", func(t *testing.T) { + t.Parallel() - ts := getSimpleCloudTestState(t, nil, nil, nil, nil) - delete(ts.Env, "K6_CLOUD_TOKEN") - ts.ExpectedExitCode = -1 // TODO: use a more specific exit code? - cmd.ExecuteWithGlobalState(ts.GlobalState) + ts := getSimpleCloudTestState(t, nil, setupCmd, nil, nil, nil) + delete(ts.Env, "K6_CLOUD_TOKEN") + ts.ExpectedExitCode = -1 // TODO: use a more specific exit code? + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdout := ts.Stdout.String() - t.Log(stdout) - assert.Contains(t, stdout, `Not logged in`) -} + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `not logged in`) + }) -func TestCloudLoggedInWithScriptToken(t *testing.T) { - t.Parallel() + t.Run("TestCloudLoggedInWithScriptToken", func(t *testing.T) { + t.Parallel() - script := ` + script := ` export let options = { ext: { loadimpact: { @@ -114,64 +62,64 @@ func TestCloudLoggedInWithScriptToken(t *testing.T) { export default function() {}; ` - ts := getSimpleCloudTestState(t, []byte(script), nil, nil, nil) - delete(ts.Env, "K6_CLOUD_TOKEN") - cmd.ExecuteWithGlobalState(ts.GlobalState) + ts := getSimpleCloudTestState(t, []byte(script), setupCmd, nil, nil, nil) + delete(ts.Env, "K6_CLOUD_TOKEN") + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdout := ts.Stdout.String() - t.Log(stdout) - assert.NotContains(t, stdout, `Not logged in`) - assert.Contains(t, stdout, `execution: cloud`) - assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) - assert.Contains(t, stdout, `test status: Finished`) -} + stdout := ts.Stdout.String() + t.Log(stdout) + assert.NotContains(t, stdout, `not logged in`) + assert.Contains(t, stdout, `execution: cloud`) + assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) + assert.Contains(t, stdout, `test status: Finished`) + }) -func TestCloudExitOnRunning(t *testing.T) { - t.Parallel() + t.Run("TestCloudExitOnRunning", func(t *testing.T) { + t.Parallel() - cs := func() cloudapi.TestProgressResponse { - return cloudapi.TestProgressResponse{ - RunStatusText: "Running", - RunStatus: cloudapi.RunStatusRunning, + cs := func() cloudapi.TestProgressResponse { + return cloudapi.TestProgressResponse{ + RunStatusText: "Running", + RunStatus: cloudapi.RunStatusRunning, + } } - } - ts := getSimpleCloudTestState(t, nil, []string{"--exit-on-running", "--log-output=stdout"}, nil, cs) - cmd.ExecuteWithGlobalState(ts.GlobalState) + ts := getSimpleCloudTestState(t, nil, setupCmd, []string{"--exit-on-running", "--log-output=stdout"}, nil, cs) + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdout := ts.Stdout.String() - t.Log(stdout) - assert.Contains(t, stdout, `execution: cloud`) - assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) - assert.Contains(t, stdout, `test status: Running`) -} + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `execution: cloud`) + assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) + assert.Contains(t, stdout, `test status: Running`) + }) -func TestCloudUploadOnly(t *testing.T) { - t.Parallel() + t.Run("TestCloudUploadOnly", func(t *testing.T) { + t.Parallel() - cs := func() cloudapi.TestProgressResponse { - return cloudapi.TestProgressResponse{ - RunStatusText: "Archived", - RunStatus: cloudapi.RunStatusArchived, + cs := func() cloudapi.TestProgressResponse { + return cloudapi.TestProgressResponse{ + RunStatusText: "Archived", + RunStatus: cloudapi.RunStatusArchived, + } } - } - ts := getSimpleCloudTestState(t, nil, []string{"--upload-only", "--log-output=stdout"}, nil, cs) - cmd.ExecuteWithGlobalState(ts.GlobalState) + ts := getSimpleCloudTestState(t, nil, setupCmd, []string{"--upload-only", "--log-output=stdout"}, nil, cs) + cmd.ExecuteWithGlobalState(ts.GlobalState) - stdout := ts.Stdout.String() - t.Log(stdout) - assert.Contains(t, stdout, `execution: cloud`) - assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) - assert.Contains(t, stdout, `test status: Archived`) -} + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `execution: cloud`) + assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) + assert.Contains(t, stdout, `test status: Archived`) + }) -func TestCloudWithConfigOverride(t *testing.T) { - t.Parallel() + t.Run("TestCloudWithConfigOverride", func(t *testing.T) { + t.Parallel() - configOverride := http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) { - resp.WriteHeader(http.StatusOK) - _, err := fmt.Fprint(resp, `{ + configOverride := http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) { + resp.WriteHeader(http.StatusOK) + _, err := fmt.Fprint(resp, `{ "reference_id": "123", "config": { "webAppURL": "https://bogus.url", @@ -183,108 +131,172 @@ func TestCloudWithConfigOverride(t *testing.T) { {"level": "error", "message": "test error"} ] }`) - assert.NoError(t, err) + assert.NoError(t, err) + }) + ts := getSimpleCloudTestState(t, nil, setupCmd, nil, configOverride, nil) + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, "execution: cloud") + assert.Contains(t, stdout, "output: something from the cloud") + assert.Contains(t, stdout, `level=debug msg="invalid message level 'invalid' for message 'test debug message'`) + assert.Contains(t, stdout, `level=error msg="test debug message" source=grafana-k6-cloud`) + assert.Contains(t, stdout, `level=warning msg="test warning" source=grafana-k6-cloud`) + assert.Contains(t, stdout, `level=error msg="test error" source=grafana-k6-cloud`) }) - ts := getSimpleCloudTestState(t, nil, nil, configOverride, nil) - cmd.ExecuteWithGlobalState(ts.GlobalState) - - stdout := ts.Stdout.String() - t.Log(stdout) - assert.Contains(t, stdout, "execution: cloud") - assert.Contains(t, stdout, "output: something from the cloud") - assert.Contains(t, stdout, `level=debug msg="invalid message level 'invalid' for message 'test debug message'`) - assert.Contains(t, stdout, `level=error msg="test debug message" source=grafana-k6-cloud`) - assert.Contains(t, stdout, `level=warning msg="test warning" source=grafana-k6-cloud`) - assert.Contains(t, stdout, `level=error msg="test error" source=grafana-k6-cloud`) -} -// TestCloudWithArchive tests that if k6 uses a static archive with the script inside that has cloud options like: -// -// export let options = { -// ext: { -// loadimpact: { -// name: "my load test", -// projectID: 124, -// note: "lorem ipsum", -// }, -// } -// }; -// -// actually sends to the cloud the archive with the correct metadata (metadata.json), like: -// -// "ext": { -// "loadimpact": { -// "name": "my load test", -// "note": "lorem ipsum", -// "projectID": 124 -// } -// } -func TestCloudWithArchive(t *testing.T) { - t.Parallel() + // TestCloudWithArchive tests that if k6 uses a static archive with the script inside that has cloud options like: + // + // export let options = { + // ext: { + // loadimpact: { + // name: "my load test", + // projectID: 124, + // note: "lorem ipsum", + // }, + // } + // }; + // + // actually sends to the cloud the archive with the correct metadata (metadata.json), like: + // + // "ext": { + // "loadimpact": { + // "name": "my load test", + // "note": "lorem ipsum", + // "projectID": 124 + // } + // } + t.Run("TestCloudWithArchive", func(t *testing.T) { + t.Parallel() + + testRunID := 123 + ts := NewGlobalTestState(t) + + archiveUpload := http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { + // check the archive + file, _, err := req.FormFile("file") + assert.NoError(t, err) + assert.NotNil(t, file) - testRunID := 123 - ts := NewGlobalTestState(t) + // temporary write the archive for file system + data, err := io.ReadAll(file) + assert.NoError(t, err) - archiveUpload := http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) { - // check the archive - file, _, err := req.FormFile("file") - assert.NoError(t, err) - assert.NotNil(t, file) + tmpPath := filepath.Join(ts.Cwd, "archive_to_cloud.tar") + require.NoError(t, fsext.WriteFile(ts.FS, tmpPath, data, 0o644)) + + // check what inside + require.NoError(t, testutils.Untar(t, ts.FS, tmpPath, "tmp/")) + + metadataRaw, err := fsext.ReadFile(ts.FS, "tmp/metadata.json") + require.NoError(t, err) + + metadata := struct { + Options struct { + Cloud struct { + Name string `json:"name"` + Note string `json:"note"` + ProjectID int `json:"projectID"` + } `json:"cloud"` + } `json:"options"` + }{} + + // then unpacked metadata should not contain any environment variables passed at the moment of archive creation + require.NoError(t, json.Unmarshal(metadataRaw, &metadata)) + require.Equal(t, "my load test", metadata.Options.Cloud.Name) + require.Equal(t, "lorem ipsum", metadata.Options.Cloud.Note) + require.Equal(t, 124, metadata.Options.Cloud.ProjectID) + + // respond with the test run ID + resp.WriteHeader(http.StatusOK) + _, err = fmt.Fprintf(resp, `{"reference_id": "%d"}`, testRunID) + assert.NoError(t, err) + }) - // temporary write the archive for file system - data, err := io.ReadAll(file) - assert.NoError(t, err) + srv := getMockCloud(t, testRunID, archiveUpload, nil) - tmpPath := filepath.Join(ts.Cwd, "archive_to_cloud.tar") - require.NoError(t, fsext.WriteFile(ts.FS, tmpPath, data, 0o644)) + data, err := os.ReadFile(filepath.Join("testdata/archives", "archive_v0.46.0_with_loadimpact_option.tar")) //nolint:forbidigo // it's a test + require.NoError(t, err) - // check what inside - require.NoError(t, testutils.Untar(t, ts.FS, tmpPath, "tmp/")) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "archive.tar"), data, 0o644)) - metadataRaw, err := fsext.ReadFile(ts.FS, "tmp/metadata.json") - require.NoError(t, err) + ts.CmdArgs = []string{"k6", "cloud", "--verbose", "--log-output=stdout", "archive.tar"} + ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet + ts.Env["K6_CLOUD_HOST"] = srv.URL + ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud - metadata := struct { - Options struct { - Cloud struct { - Name string `json:"name"` - Note string `json:"note"` - ProjectID int `json:"projectID"` - } `json:"cloud"` - } `json:"options"` - }{} - - // then unpacked metadata should not contain any environment variables passed at the moment of archive creation - require.NoError(t, json.Unmarshal(metadataRaw, &metadata)) - require.Equal(t, "my load test", metadata.Options.Cloud.Name) - require.Equal(t, "lorem ipsum", metadata.Options.Cloud.Note) - require.Equal(t, 124, metadata.Options.Cloud.ProjectID) - - // respond with the test run ID + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + assert.NotContains(t, stdout, `not logged in`) + assert.Contains(t, stdout, `execution: cloud`) + assert.Contains(t, stdout, `hello world from archive`) + assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) + assert.Contains(t, stdout, `test status: Finished`) + }) +} + +func cloudTestStartSimple(tb testing.TB, testRunID int) http.Handler { + return http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) { resp.WriteHeader(http.StatusOK) - _, err = fmt.Fprintf(resp, `{"reference_id": "%d"}`, testRunID) - assert.NoError(t, err) + _, err := fmt.Fprintf(resp, `{"reference_id": "%d"}`, testRunID) + assert.NoError(tb, err) + }) +} + +func getMockCloud( + t *testing.T, testRunID int, + archiveUpload http.Handler, progressCallback func() cloudapi.TestProgressResponse, +) *httptest.Server { + if archiveUpload == nil { + archiveUpload = cloudTestStartSimple(t, testRunID) + } + testProgressURL := fmt.Sprintf("GET ^/v1/test-progress/%d$", testRunID) + defaultProgress := cloudapi.TestProgressResponse{ + RunStatusText: "Finished", + RunStatus: cloudapi.RunStatusFinished, + ResultStatus: cloudapi.ResultStatusPassed, + Progress: 1, + } + + srv := getTestServer(t, map[string]http.Handler{ + "POST ^/v1/archive-upload$": archiveUpload, + testProgressURL: http.HandlerFunc(func(resp http.ResponseWriter, _ *http.Request) { + testProgress := defaultProgress + if progressCallback != nil { + testProgress = progressCallback() + } + respBody, err := json.Marshal(testProgress) + assert.NoError(t, err) + _, err = fmt.Fprint(resp, string(respBody)) + assert.NoError(t, err) + }), }) - srv := getMockCloud(t, testRunID, archiveUpload, nil) + t.Cleanup(srv.Close) + + return srv +} + +func getSimpleCloudTestState(t *testing.T, script []byte, setupCmd setupCommandFunc, cliFlags []string, archiveUpload http.Handler, progressCallback func() cloudapi.TestProgressResponse) *GlobalTestState { + if script == nil { + script = []byte(`export default function() {}`) + } - data, err := os.ReadFile(filepath.Join("testdata/archives", "archive_v0.46.0_with_loadimpact_option.tar")) //nolint:forbidigo // it's a test - require.NoError(t, err) + if cliFlags == nil { + cliFlags = []string{"--verbose", "--log-output=stdout"} + } - require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "archive.tar"), data, 0o644)) + srv := getMockCloud(t, 123, archiveUpload, progressCallback) - ts.CmdArgs = []string{"k6", "cloud", "--verbose", "--log-output=stdout", "archive.tar"} + ts := NewGlobalTestState(t) + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "test.js"), script, 0o644)) + ts.CmdArgs = setupCmd(cliFlags) ts.Env["K6_SHOW_CLOUD_LOGS"] = "false" // no mock for the logs yet ts.Env["K6_CLOUD_HOST"] = srv.URL ts.Env["K6_CLOUD_TOKEN"] = "foo" // doesn't matter, we mock the cloud - cmd.ExecuteWithGlobalState(ts.GlobalState) - - stdout := ts.Stdout.String() - t.Log(stdout) - assert.NotContains(t, stdout, `Not logged in`) - assert.Contains(t, stdout, `execution: cloud`) - assert.Contains(t, stdout, `hello world from archive`) - assert.Contains(t, stdout, `output: https://app.k6.io/runs/123`) - assert.Contains(t, stdout, `test status: Finished`) + return ts }