diff --git a/cmd/cloud.go b/cmd/cloud.go index bfb79015563..a7479e8dc41 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -12,18 +12,17 @@ import ( "sync" "time" - "github.com/fatih/color" "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/fatih/color" "github.com/spf13/cobra" "github.com/spf13/pflag" - - "go.k6.io/k6/cmd/state" ) // cmdCloud handles the `k6 cloud` sub-command @@ -335,6 +334,9 @@ func (c *cmdCloud) flagSet() *pflag.FlagSet { "enable showing of logs when a test is executed in the cloud") flags.BoolVar(&c.uploadOnly, "upload-only", c.uploadOnly, "only upload the test to the cloud without actually starting a test run") + if err := flags.MarkDeprecated("upload-only", "use \"k6 cloud upload\" instead"); err != nil { + panic(err) // Should never happen + } return flags } @@ -383,6 +385,7 @@ service. Be sure to run the "k6 cloud login" command prior to authenticate with // Register `k6 cloud` subcommands cloudCmd.AddCommand(getCmdCloudRun(gs)) cloudCmd.AddCommand(getCmdCloudLogin(gs)) + cloudCmd.AddCommand(getCmdCloudUpload(c)) cloudCmd.Flags().SortFlags = false cloudCmd.Flags().AddFlagSet(c.flagSet()) diff --git a/cmd/cloud_login.go b/cmd/cloud_login.go index f05c35620fc..376a404e64a 100644 --- a/cmd/cloud_login.go +++ b/cmd/cloud_login.go @@ -29,16 +29,16 @@ func getCmdCloudLogin(gs *state.GlobalState) *cobra.Command { // loginCloudCommand represents the 'cloud login' command exampleText := getExampleText(gs, ` # Prompt for a Grafana Cloud k6 token - {{.}} cloud login + $ {{.}} cloud login # Store a token in k6's persistent configuration - {{.}} cloud login -t + $ {{.}} cloud login -t # Display the stored token - {{.}} cloud login -s + $ {{.}} cloud login -s # Reset the stored token - {{.}} cloud login -r`[1:]) + $ {{.}} cloud login -r`[1:]) loginCloudCommand := &cobra.Command{ Use: cloudLoginCommandName, diff --git a/cmd/cloud_upload.go b/cmd/cloud_upload.go new file mode 100644 index 00000000000..81c8a2a970f --- /dev/null +++ b/cmd/cloud_upload.go @@ -0,0 +1,68 @@ +package cmd + +import ( + "go.k6.io/k6/cmd/state" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +const cloudUploadCommandName = "upload" + +type cmdCloudUpload struct { + globalState *state.GlobalState + + // deprecatedCloudCmd holds an instance of the k6 cloud command that we store + // in order to be able to call its run method to support the cloud upload + // feature + deprecatedCloudCmd *cmdCloud +} + +func getCmdCloudUpload(cloudCmd *cmdCloud) *cobra.Command { + c := &cmdCloudUpload{ + globalState: cloudCmd.gs, + deprecatedCloudCmd: cloudCmd, + } + + // uploadCloudCommand represents the 'cloud upload' command + exampleText := getExampleText(cloudCmd.gs, ` + # Upload the test script and its resources to the Grafana Cloud k6 without actually starting a test run + $ {{.}} cloud upload script.js`[1:]) + + uploadCloudCommand := &cobra.Command{ + Use: cloudUploadCommandName, + Short: "Upload the test script to the Grafana Cloud k6", + Long: `Upload the test script and its resources to the Grafana Cloud k6. + +This will upload the test script and its resources to the Grafana Cloud k6 service. +Using this command requires to be authenticated against the Grafana Cloud k6. +Use the "k6 cloud login" command to authenticate. +`, + Example: exampleText, + Args: exactArgsWithMsg(1, "arg should either be \"-\", if reading script from stdin, or a path to a script file"), + PreRunE: c.preRun, + RunE: c.run, + } + + uploadCloudCommand.Flags().AddFlagSet(c.flagSet()) + + return uploadCloudCommand +} + +func (c *cmdCloudUpload) preRun(cmd *cobra.Command, args []string) error { + return c.deprecatedCloudCmd.preRun(cmd, args) +} + +// run is the code that runs when the user executes `k6 cloud upload` +func (c *cmdCloudUpload) run(cmd *cobra.Command, args []string) error { + c.deprecatedCloudCmd.uploadOnly = true + return c.deprecatedCloudCmd.run(cmd, args) +} + +func (c *cmdCloudUpload) flagSet() *pflag.FlagSet { + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + flags.SortFlags = false + flags.AddFlagSet(optionFlagSet()) + flags.AddFlagSet(runtimeOptionFlagSet(false)) + return flags +} diff --git a/cmd/tests/cmd_cloud_upload_test.go b/cmd/tests/cmd_cloud_upload_test.go new file mode 100644 index 00000000000..29c748867b5 --- /dev/null +++ b/cmd/tests/cmd_cloud_upload_test.go @@ -0,0 +1,157 @@ +package tests + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "go.k6.io/k6/cloudapi" + "go.k6.io/k6/cmd" + "go.k6.io/k6/lib/fsext" + "go.k6.io/k6/lib/testutils" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestK6CloudUpload(t *testing.T) { + t.Parallel() + + t.Run("TestCloudUploadNotLoggedIn", func(t *testing.T) { + t.Parallel() + + ts := getSimpleCloudTestState(t, nil, setupK6CloudUploadCmd, nil, nil, nil) + delete(ts.Env, "K6_CLOUD_TOKEN") + ts.ExpectedExitCode = -1 + cmd.ExecuteWithGlobalState(ts.GlobalState) + + stdout := ts.Stdout.String() + t.Log(stdout) + assert.Contains(t, stdout, `not logged in`) + }) + + t.Run("TestCloudUploadWithScript", func(t *testing.T) { + t.Parallel() + + cs := func() cloudapi.TestProgressResponse { + return cloudapi.TestProgressResponse{ + RunStatusText: "Archived", + RunStatus: cloudapi.RunStatusArchived, + } + } + + ts := getSimpleCloudTestState(t, nil, setupK6CloudUploadCmd, nil, 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`) + }) + + // TestCloudUploadWithArchive 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("TestCloudUploadWithArchive", 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) + + // temporary write the archive for file system + data, err := io.ReadAll(file) + assert.NoError(t, err) + + 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) + }) + + cs := func() cloudapi.TestProgressResponse { + return cloudapi.TestProgressResponse{ + RunStatusText: "Archived", + RunStatus: cloudapi.RunStatusArchived, + } + } + + srv := getMockCloud(t, testRunID, archiveUpload, cs) + + 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) + + require.NoError(t, fsext.WriteFile(ts.FS, filepath.Join(ts.Cwd, "archive.tar"), data, 0o644)) + + ts.CmdArgs = []string{"k6", "cloud", "upload", "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 + + 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: Archived`) + }) +} + +func setupK6CloudUploadCmd(cliFlags []string) []string { + return append([]string{"k6", "cloud", "upload"}, append(cliFlags, "test.js")...) +}