From f223cd56a9bb21c3721314f87e6f97c7d9dd43fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ivan=20Miri=C4=87?= Date: Fri, 16 Dec 2022 13:29:01 +0100 Subject: [PATCH] Move cmd.globalState to new cmd/state package This allows us to use it in tests outside of the cmd package. See https://github.com/grafana/k6/issues/2459 --- cmd/archive.go | 10 +- cmd/archive_test.go | 37 ++--- cmd/cloud.go | 33 ++--- cmd/common.go | 9 +- cmd/config.go | 25 ++-- cmd/config_consolidation_test.go | 26 ++-- cmd/convert.go | 11 +- cmd/convert_test.go | 31 ++-- cmd/inspect.go | 9 +- cmd/login.go | 4 +- cmd/login_cloud.go | 25 ++-- cmd/login_influxdb.go | 11 +- cmd/outputs.go | 13 +- cmd/panic_integration_test.go | 13 +- cmd/pause.go | 9 +- cmd/resume.go | 9 +- cmd/root.go | 240 +++++++------------------------ cmd/root_test.go | 240 ------------------------------- cmd/run.go | 39 ++--- cmd/run_test.go | 39 ++--- cmd/runtime_options_test.go | 13 +- cmd/scale.go | 9 +- cmd/state/buffer.go | 49 +++++++ cmd/state/doc.go | 4 + cmd/state/state.go | 133 +++++++++++++++++ cmd/state/test_state.go | 141 ++++++++++++++++++ cmd/stats.go | 9 +- cmd/status.go | 9 +- cmd/test_load.go | 43 +++--- cmd/ui.go | 17 ++- cmd/version.go | 8 +- main.go | 5 +- 32 files changed, 629 insertions(+), 644 deletions(-) delete mode 100644 cmd/root_test.go create mode 100644 cmd/state/buffer.go create mode 100644 cmd/state/doc.go create mode 100644 cmd/state/state.go create mode 100644 cmd/state/test_state.go diff --git a/cmd/archive.go b/cmd/archive.go index 3e0aa7a4440a..0ffb3dc45dd9 100644 --- a/cmd/archive.go +++ b/cmd/archive.go @@ -3,11 +3,13 @@ package cmd import ( "github.com/spf13/cobra" "github.com/spf13/pflag" + + "go.k6.io/k6/cmd/state" ) // cmdArchive handles the `k6 archive` sub-command type cmdArchive struct { - gs *globalState + gs *state.GlobalState archiveOut string excludeEnvVars bool @@ -31,13 +33,13 @@ func (c *cmdArchive) run(cmd *cobra.Command, args []string) error { // Archive. arc := testRunState.Runner.MakeArchive() - f, err := c.gs.fs.Create(c.archiveOut) + f, err := c.gs.FS.Create(c.archiveOut) if err != nil { return err } if c.excludeEnvVars { - c.gs.logger.Debug("environment variables will be excluded from the archive") + c.gs.Logger.Debug("environment variables will be excluded from the archive") arc.Env = nil } @@ -66,7 +68,7 @@ func (c *cmdArchive) flagSet() *pflag.FlagSet { return flags } -func getCmdArchive(gs *globalState) *cobra.Command { +func getCmdArchive(gs *state.GlobalState) *cobra.Command { c := &cmdArchive{ gs: gs, archiveOut: "archive.tar", diff --git a/cmd/archive_test.go b/cmd/archive_test.go index e7a75c6e56c0..d0db9562ea83 100644 --- a/cmd/archive_test.go +++ b/cmd/archive_test.go @@ -13,6 +13,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" ) @@ -78,17 +79,17 @@ func TestArchiveThresholds(t *testing.T) { testScript, err := ioutil.ReadFile(testCase.testFilename) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, testCase.testFilename), testScript, 0o644)) - testState.args = []string{"k6", "archive", testCase.testFilename} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, testCase.testFilename), testScript, 0o644)) + ts.CmdArgs = []string{"k6", "archive", testCase.testFilename} if testCase.noThresholds { - testState.args = append(testState.args, "--no-thresholds") + ts.CmdArgs = append(ts.CmdArgs, "--no-thresholds") } if testCase.wantErr { - testState.expectedExitCode = int(exitcodes.InvalidConfig) + ts.ExpectedExitCode = int(exitcodes.InvalidConfig) } - newRootCommand(testState.globalState).execute() + newRootCommand(ts.GlobalState).execute() }) } } @@ -99,16 +100,16 @@ func TestArchiveContainsEnv(t *testing.T) { // given some script that will be archived fileName := "script.js" testScript := []byte(`export default function () {}`) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, fileName), testScript, 0o644)) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, fileName), testScript, 0o644)) // when we do archiving and passing the `--env` flags - testState.args = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", fileName} + ts.CmdArgs = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", fileName} - newRootCommand(testState.globalState).execute() - require.NoError(t, untar(t, testState.fs, "archive.tar", "tmp/")) + newRootCommand(ts.GlobalState).execute() + require.NoError(t, untar(t, ts.FS, "archive.tar", "tmp/")) - data, err := afero.ReadFile(testState.fs, "tmp/metadata.json") + data, err := afero.ReadFile(ts.FS, "tmp/metadata.json") require.NoError(t, err) metadata := struct { @@ -132,16 +133,16 @@ func TestArchiveNotContainsEnv(t *testing.T) { // given some script that will be archived fileName := "script.js" testScript := []byte(`export default function () {}`) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, fileName), testScript, 0o644)) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, fileName), testScript, 0o644)) // when we do archiving and passing the `--env` flags altogether with `--exclude-env-vars` flag - testState.args = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", "--exclude-env-vars", fileName} + ts.CmdArgs = []string{"k6", "--env", "ENV1=lorem", "--env", "ENV2=ipsum", "archive", "--exclude-env-vars", fileName} - newRootCommand(testState.globalState).execute() - require.NoError(t, untar(t, testState.fs, "archive.tar", "tmp/")) + newRootCommand(ts.GlobalState).execute() + require.NoError(t, untar(t, ts.FS, "archive.tar", "tmp/")) - data, err := afero.ReadFile(testState.fs, "tmp/metadata.json") + data, err := afero.ReadFile(ts.FS, "tmp/metadata.json") require.NoError(t, err) metadata := struct { diff --git a/cmd/cloud.go b/cmd/cloud.go index 859e623e6839..b355331d39e3 100644 --- a/cmd/cloud.go +++ b/cmd/cloud.go @@ -16,6 +16,7 @@ import ( "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" @@ -25,7 +26,7 @@ import ( // cmdCloud handles the `k6 cloud` sub-command type cmdCloud struct { - gs *globalState + gs *state.GlobalState showCloudLogs bool exitOnRunning bool @@ -37,7 +38,7 @@ func (c *cmdCloud) preRun(cmd *cobra.Command, args []string) error { // We deliberately parse the env variables, to validate for wrong // values, even if we don't subsequently use them (if the respective // CLI flag was specified, since it has a higher priority). - if showCloudLogsEnv, ok := c.gs.envVars["K6_SHOW_CLOUD_LOGS"]; ok { + if showCloudLogsEnv, ok := c.gs.Env["K6_SHOW_CLOUD_LOGS"]; ok { showCloudLogsValue, err := strconv.ParseBool(showCloudLogsEnv) if err != nil { return fmt.Errorf("parsing K6_SHOW_CLOUD_LOGS returned an error: %w", err) @@ -47,7 +48,7 @@ func (c *cmdCloud) preRun(cmd *cobra.Command, args []string) error { } } - if exitOnRunningEnv, ok := c.gs.envVars["K6_EXIT_ON_RUNNING"]; ok { + if exitOnRunningEnv, ok := c.gs.Env["K6_EXIT_ON_RUNNING"]; ok { exitOnRunningValue, err := strconv.ParseBool(exitOnRunningEnv) if err != nil { return fmt.Errorf("parsing K6_EXIT_ON_RUNNING returned an error: %w", err) @@ -112,7 +113,7 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { // Cloud config cloudConfig, err := cloudapi.GetConsolidatedConfig( - test.derivedConfig.Collectors["cloud"], c.gs.envVars, "", arc.Options.External) + test.derivedConfig.Collectors["cloud"], c.gs.Env, "", arc.Options.External) if err != nil { return err } @@ -146,10 +147,10 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { name = filepath.Base(test.sourceRootPath) } - globalCtx, globalCancel := context.WithCancel(c.gs.ctx) + globalCtx, globalCancel := context.WithCancel(c.gs.Ctx) defer globalCancel() - logger := c.gs.logger + logger := c.gs.Logger // Start cloud test run progressBar.Modify(pb.WithConstProgress(0, "Validating script options")) @@ -196,13 +197,13 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { executionPlan := test.derivedConfig.Scenarios.GetFullExecutionRequirements(et) execDesc := getExecutionDescription( - c.gs.console.ApplyTheme, "cloud", test.sourceRootPath, testURL, test.derivedConfig, + c.gs.Console.ApplyTheme, "cloud", test.sourceRootPath, testURL, test.derivedConfig, et, executionPlan, nil, ) - if c.gs.flags.quiet { - c.gs.logger.Debug(execDesc) + if c.gs.Flags.Quiet { + c.gs.Logger.Debug(execDesc) } else { - c.gs.console.Print(execDesc) + c.gs.Console.Print(execDesc) } progressBar.Modify( @@ -213,12 +214,12 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { progressCtx, progressCancel := context.WithCancel(globalCtx) defer progressCancel() - if !c.gs.flags.quiet { + if !c.gs.Flags.Quiet { progressBarWG := &sync.WaitGroup{} progressBarWG.Add(1) defer progressBarWG.Wait() go func() { - c.gs.console.ShowProgress(progressCtx, []*pb.ProgressBar{progressBar}) + c.gs.Console.ShowProgress(progressCtx, []*pb.ProgressBar{progressBar}) progressBarWG.Done() }() } @@ -293,9 +294,9 @@ func (c *cmdCloud) run(cmd *cobra.Command, args []string) error { return errext.WithExitCodeIfNone(errors.New("Test progress error"), exitcodes.CloudFailedToGetProgress) } - if !c.gs.flags.quiet { - c.gs.console.Printf(" test status: %s\n", - c.gs.console.ApplyTheme(testProgress.RunStatusText)) + if !c.gs.Flags.Quiet { + c.gs.Console.Printf(" test status: %s\n", + c.gs.Console.ApplyTheme(testProgress.RunStatusText)) } else { logger.WithField("run_status", testProgress.RunStatusText).Debug("Test finished") } @@ -324,7 +325,7 @@ func (c *cmdCloud) flagSet() *pflag.FlagSet { return flags } -func getCmdCloud(gs *globalState) *cobra.Command { +func getCmdCloud(gs *state.GlobalState) *cobra.Command { c := &cmdCloud{ gs: gs, showCloudLogs: true, diff --git a/cmd/common.go b/cmd/common.go index da865234adc5..df6a6b0744b1 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/pflag" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib" "go.k6.io/k6/lib/types" @@ -70,10 +71,10 @@ func exactArgsWithMsg(n int, msg string) cobra.PositionalArgs { } // Trap Interrupts, SIGINTs and SIGTERMs and call the given. -func handleTestAbortSignals(gs *globalState, gracefulStopHandler, onHardStop func(os.Signal)) (stop func()) { +func handleTestAbortSignals(gs *state.GlobalState, gracefulStopHandler, onHardStop func(os.Signal)) (stop func()) { sigC := make(chan os.Signal, 2) done := make(chan struct{}) - gs.signalNotify(sigC, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) + gs.SignalNotify(sigC, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) go func() { select { @@ -90,7 +91,7 @@ func handleTestAbortSignals(gs *globalState, gracefulStopHandler, onHardStop fun } // If we get a second signal, we immediately exit, so something like // https://github.com/k6io/k6/issues/971 never happens again - gs.osExit(int(exitcodes.ExternalAbort)) + gs.OSExit(int(exitcodes.ExternalAbort)) case <-done: return } @@ -98,7 +99,7 @@ func handleTestAbortSignals(gs *globalState, gracefulStopHandler, onHardStop fun return func() { close(done) - gs.signalStop(sigC) + gs.SignalStop(sigC) } } diff --git a/cmd/config.go b/cmd/config.go index c131ef4db04c..ea0ac8fd756a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/pflag" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib" @@ -104,10 +105,10 @@ func getConfig(flags *pflag.FlagSet) (Config, error) { // an error. The only situation in which an error won't be returned is if the // user didn't explicitly specify a config file path and the default config file // doesn't exist. -func readDiskConfig(globalState *globalState) (Config, error) { +func readDiskConfig(gs *state.GlobalState) (Config, error) { // Try to see if the file exists in the supplied filesystem - if _, err := globalState.fs.Stat(globalState.flags.configFilePath); err != nil { - if os.IsNotExist(err) && globalState.flags.configFilePath == globalState.defaultFlags.configFilePath { + if _, err := gs.FS.Stat(gs.Flags.ConfigFilePath); err != nil { + if os.IsNotExist(err) && gs.Flags.ConfigFilePath == gs.DefaultFlags.ConfigFilePath { // If the file doesn't exist, but it was the default config file (i.e. the user // didn't specify anything), silence the error err = nil @@ -115,31 +116,31 @@ func readDiskConfig(globalState *globalState) (Config, error) { return Config{}, err } - data, err := afero.ReadFile(globalState.fs, globalState.flags.configFilePath) + data, err := afero.ReadFile(gs.FS, gs.Flags.ConfigFilePath) if err != nil { - return Config{}, fmt.Errorf("couldn't load the configuration from %q: %w", globalState.flags.configFilePath, err) + return Config{}, fmt.Errorf("couldn't load the configuration from %q: %w", gs.Flags.ConfigFilePath, err) } var conf Config err = json.Unmarshal(data, &conf) if err != nil { - return Config{}, fmt.Errorf("couldn't parse the configuration from %q: %w", globalState.flags.configFilePath, err) + return Config{}, fmt.Errorf("couldn't parse the configuration from %q: %w", gs.Flags.ConfigFilePath, err) } return conf, nil } // Serializes the configuration to a JSON file and writes it in the supplied // location on the supplied filesystem -func writeDiskConfig(globalState *globalState, conf Config) error { +func writeDiskConfig(gs *state.GlobalState, conf Config) error { data, err := json.MarshalIndent(conf, "", " ") if err != nil { return err } - if err := globalState.fs.MkdirAll(filepath.Dir(globalState.flags.configFilePath), 0o755); err != nil { + if err := gs.FS.MkdirAll(filepath.Dir(gs.Flags.ConfigFilePath), 0o755); err != nil { return err } - return afero.WriteFile(globalState.fs, globalState.flags.configFilePath, data, 0o644) + return afero.WriteFile(gs.FS, gs.Flags.ConfigFilePath, data, 0o644) } // Reads configuration variables from the environment. @@ -162,14 +163,14 @@ func readEnvConfig(envMap map[string]string) (Config, error) { // - set some defaults if they weren't previously specified // TODO: add better validation, more explicit default values and improve consistency between formats // TODO: accumulate all errors and differentiate between the layers? -func getConsolidatedConfig(globalState *globalState, cliConf Config, runnerOpts lib.Options) (conf Config, err error) { +func getConsolidatedConfig(gs *state.GlobalState, cliConf Config, runnerOpts lib.Options) (conf Config, err error) { // TODO: use errext.WithExitCodeIfNone(err, exitcodes.InvalidConfig) where it makes sense? - fileConf, err := readDiskConfig(globalState) + fileConf, err := readDiskConfig(gs) if err != nil { return conf, err } - envConf, err := readEnvConfig(globalState.envVars) + envConf, err := readEnvConfig(gs.Env) if err != nil { return conf, err } diff --git a/cmd/config_consolidation_test.go b/cmd/config_consolidation_test.go index 1dc27c49ba98..6eeea8c71bbf 100644 --- a/cmd/config_consolidation_test.go +++ b/cmd/config_consolidation_test.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "path/filepath" "testing" "time" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" "go.k6.io/k6/lib/executor" "go.k6.io/k6/lib/types" @@ -143,11 +143,9 @@ type configConsolidationTestCase struct { } func getConfigConsolidationTestCases() []configConsolidationTestCase { + defaultFlags := state.GetDefaultFlags(".config") defaultConfig := func(jsonConfig string) afero.Fs { - return getFS([]file{{ - filepath.Join(".config", "loadimpact", "k6", defaultConfigFileName), // TODO: improve - jsonConfig, - }}) + return getFS([]file{{defaultFlags.ConfigFilePath, jsonConfig}}) } I := null.IntFrom // shortcut for "Valid" (i.e. user-specified) ints // This is a function, because some of these test cases actually need for the init() functions @@ -488,15 +486,15 @@ func getConfigConsolidationTestCases() []configConsolidationTestCase { func runTestCase(t *testing.T, testCase configConsolidationTestCase, subCmd string) { t.Logf("Test for `k6 %s` with opts=%#v and exp=%#v\n", subCmd, testCase.options, testCase.expected) - ts := newGlobalTestState(t) - ts.args = append([]string{"k6", subCmd}, testCase.options.cli...) - ts.envVars = buildEnvMap(testCase.options.env) + ts := state.NewGlobalTestState(t) + ts.CmdArgs = append([]string{"k6", subCmd}, testCase.options.cli...) + ts.Env = lib.BuildEnvMap(testCase.options.env) if testCase.options.fs != nil { - ts.globalState.fs = testCase.options.fs + ts.GlobalState.FS = testCase.options.fs } - rootCmd := newRootCommand(ts.globalState) - cmd, args, err := rootCmd.cmd.Find(ts.args[1:]) + rootCmd := newRootCommand(ts.GlobalState) + cmd, args, err := rootCmd.cmd.Find(ts.CmdArgs[1:]) require.NoError(t, err) err = cmd.ParseFlags(args) @@ -526,7 +524,7 @@ func runTestCase(t *testing.T, testCase configConsolidationTestCase, subCmd stri if testCase.options.runner != nil { opts = *testCase.options.runner } - consolidatedConfig, err := getConsolidatedConfig(ts.globalState, cliConf, opts) + consolidatedConfig, err := getConsolidatedConfig(ts.GlobalState, cliConf, opts) if testCase.expected.consolidationError { require.Error(t, err) return @@ -534,14 +532,14 @@ func runTestCase(t *testing.T, testCase configConsolidationTestCase, subCmd stri require.NoError(t, err) derivedConfig := consolidatedConfig - derivedConfig.Options, err = executor.DeriveScenariosFromShortcuts(consolidatedConfig.Options, ts.logger) + derivedConfig.Options, err = executor.DeriveScenariosFromShortcuts(consolidatedConfig.Options, ts.Logger) if testCase.expected.derivationError { require.Error(t, err) return } require.NoError(t, err) - if warnings := ts.loggerHook.Drain(); testCase.expected.logWarning { + if warnings := ts.LoggerHook.Drain(); testCase.expected.logWarning { assert.NotEmpty(t, warnings) } else { assert.Empty(t, warnings) diff --git a/cmd/convert.go b/cmd/convert.go index 10ba60f9dd5c..de684c72b87b 100644 --- a/cmd/convert.go +++ b/cmd/convert.go @@ -8,13 +8,14 @@ import ( "github.com/spf13/cobra" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/converter/har" "go.k6.io/k6/lib" ) // TODO: split apart like `k6 run` and `k6 archive`? //nolint:funlen,gocognit -func getCmdConvert(globalState *globalState) *cobra.Command { +func getCmdConvert(gs *state.GlobalState) *cobra.Command { var ( convertOutput string optionsFilePath string @@ -48,7 +49,7 @@ func getCmdConvert(globalState *globalState) *cobra.Command { Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { // Parse the HAR file - r, err := globalState.fs.Open(args[0]) + r, err := gs.FS.Open(args[0]) if err != nil { return err } @@ -64,7 +65,7 @@ func getCmdConvert(globalState *globalState) *cobra.Command { options := lib.Options{MaxRedirects: null.IntFrom(0)} if optionsFilePath != "" { - optionsFileContents, readErr := afero.ReadFile(globalState.fs, optionsFilePath) + optionsFileContents, readErr := afero.ReadFile(gs.FS, optionsFilePath) if readErr != nil { return readErr } @@ -84,11 +85,11 @@ func getCmdConvert(globalState *globalState) *cobra.Command { // Write script content to stdout or file if convertOutput == "" || convertOutput == "-" { //nolint:nestif - if _, err := io.WriteString(globalState.console.Stdout, script); err != nil { + if _, err := io.WriteString(gs.Console.Stdout, script); err != nil { return err } } else { - f, err := globalState.fs.Create(convertOutput) + f, err := gs.FS.Create(convertOutput) if err != nil { return err } diff --git a/cmd/convert_test.go b/cmd/convert_test.go index eeae1b9b9b12..d7b99987ca65 100644 --- a/cmd/convert_test.go +++ b/cmd/convert_test.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" ) const testHAR = ` @@ -107,16 +108,16 @@ func TestConvertCmdCorrelate(t *testing.T) { expectedTestPlan, err := ioutil.ReadFile("testdata/example.js") require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, "correlate.har", har, 0o644)) - testState.args = []string{ + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, "correlate.har", har, 0o644)) + ts.CmdArgs = []string{ "k6", "convert", "--output=result.js", "--correlate=true", "--no-batch=true", "--enable-status-code-checks=true", "--return-on-failed-check=true", "correlate.har", } - newRootCommand(testState.globalState).execute() + newRootCommand(ts.GlobalState).execute() - result, err := afero.ReadFile(testState.fs, "result.js") + result, err := afero.ReadFile(ts.FS, "result.js") require.NoError(t, err) // Sanitizing to avoid windows problems with carriage returns @@ -142,24 +143,24 @@ func TestConvertCmdCorrelate(t *testing.T) { func TestConvertCmdStdout(t *testing.T) { t.Parallel() - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, "stdout.har", []byte(testHAR), 0o644)) - testState.args = []string{"k6", "convert", "stdout.har"} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, "stdout.har", []byte(testHAR), 0o644)) + ts.CmdArgs = []string{"k6", "convert", "stdout.har"} - newRootCommand(testState.globalState).execute() - assert.Equal(t, "Command \"convert\" is deprecated, please use har-to-k6 (https://github.com/grafana/har-to-k6) instead.\n"+testHARConvertResult, testState.stdOut.String()) + newRootCommand(ts.GlobalState).execute() + assert.Equal(t, "Command \"convert\" is deprecated, please use har-to-k6 (https://github.com/grafana/har-to-k6) instead.\n"+testHARConvertResult, ts.Stdout.String()) } func TestConvertCmdOutputFile(t *testing.T) { t.Parallel() - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, "output.har", []byte(testHAR), 0o644)) - testState.args = []string{"k6", "convert", "--output", "result.js", "output.har"} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, "output.har", []byte(testHAR), 0o644)) + ts.CmdArgs = []string{"k6", "convert", "--output", "result.js", "output.har"} - newRootCommand(testState.globalState).execute() + newRootCommand(ts.GlobalState).execute() - output, err := afero.ReadFile(testState.fs, "result.js") + output, err := afero.ReadFile(ts.FS, "result.js") assert.NoError(t, err) assert.Equal(t, testHARConvertResult, string(output)) } diff --git a/cmd/inspect.go b/cmd/inspect.go index 7009a1d3a448..44c510812957 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -5,12 +5,13 @@ import ( "github.com/spf13/cobra" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" "go.k6.io/k6/lib/types" ) // TODO: split apart like `k6 run` and `k6 archive` -func getCmdInspect(gs *globalState) *cobra.Command { +func getCmdInspect(gs *state.GlobalState) *cobra.Command { var addExecReqs bool // inspectCmd represents the inspect command @@ -42,7 +43,7 @@ func getCmdInspect(gs *globalState) *cobra.Command { if err != nil { return err } - gs.console.Print(string(data)) + gs.Console.Printf(string(data)) return nil }, @@ -60,7 +61,9 @@ func getCmdInspect(gs *globalState) *cobra.Command { // If --execution-requirements is enabled, this will consolidate the config, // derive the value of `scenarios` and calculate the max test duration and VUs. -func inspectOutputWithExecRequirements(gs *globalState, cmd *cobra.Command, test *loadedTest) (interface{}, error) { +func inspectOutputWithExecRequirements( + gs *state.GlobalState, cmd *cobra.Command, test *loadedTest, +) (interface{}, error) { // we don't actually support CLI flags here, so we pass nil as the getter configuredTest, err := test.consolidateDeriveAndValidateConfig(gs, cmd, nil) if err != nil { diff --git a/cmd/login.go b/cmd/login.go index 0911966ad569..902ad2ae3d29 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -2,10 +2,12 @@ package cmd import ( "github.com/spf13/cobra" + + "go.k6.io/k6/cmd/state" ) // getCmdLogin returns the `k6 login` sub-command, together with its children. -func getCmdLogin(gs *globalState) *cobra.Command { +func getCmdLogin(gs *state.GlobalState) *cobra.Command { loginCmd := &cobra.Command{ Use: "login", Short: "Authenticate with a service", diff --git a/cmd/login_cloud.go b/cmd/login_cloud.go index fbcf02e47600..598722e08d66 100644 --- a/cmd/login_cloud.go +++ b/cmd/login_cloud.go @@ -10,12 +10,13 @@ import ( "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/console/form" ) //nolint:funlen,gocognit -func getCmdLoginCloud(globalState *globalState) *cobra.Command { +func getCmdLoginCloud(gs *state.GlobalState) *cobra.Command { // loginCloudCommand represents the 'login cloud' command loginCloudCommand := &cobra.Command{ Use: "cloud", @@ -34,7 +35,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, k6 login cloud`[1:], Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - currentDiskConf, err := readDiskConfig(globalState) + currentDiskConf, err := readDiskConfig(gs) if err != nil { return err } @@ -51,7 +52,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, // We want to use this fully consolidated config for things like // host addresses, so users can overwrite them with env vars. consolidatedCurrentConfig, err := cloudapi.GetConsolidatedConfig( - currentJSONConfigRaw, globalState.envVars, "", nil) + currentJSONConfigRaw, gs.Env, "", nil) if err != nil { return err } @@ -65,7 +66,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, switch { case reset.Valid: newCloudConf.Token = null.StringFromPtr(nil) - globalState.console.Print(" token reset\n") + gs.Console.Print(" token reset\n") case show.Bool: case token.Valid: newCloudConf.Token = token @@ -83,10 +84,10 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, }, } if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert - globalState.logger.Warn("Stdin is not a terminal, falling back to plain text input") + gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input") } var vals map[string]string - vals, err = f.Run(globalState.console.Stdin, globalState.console.Stdout) + vals, err = f.Run(gs.Console.Stdin, gs.Console.Stdout) if err != nil { return err } @@ -94,7 +95,7 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, password := vals["Password"] client := cloudapi.NewClient( - globalState.logger, + gs.Logger, "", consolidatedCurrentConfig.Host.String, consts.Version, @@ -120,17 +121,15 @@ This will set the default token used when just "k6 run -o cloud" is passed.`, if err != nil { return err } - if err := writeDiskConfig(globalState, currentDiskConf); err != nil { + if err := writeDiskConfig(gs, currentDiskConf); err != nil { return err } if newCloudConf.Token.Valid { - if !globalState.flags.quiet { - globalState.console.Printf( - " token: %s\n", globalState.console.ApplyTheme(newCloudConf.Token.String)) + if !gs.Flags.Quiet { + gs.Console.Printf(" token: %s\n", gs.Console.ApplyTheme(newCloudConf.Token.String)) } - globalState.console.Printf( - "Logged in successfully, token saved in %s\n", globalState.flags.configFilePath) + gs.Console.Printf("Logged in successfully, token saved in %s\n", gs.Flags.ConfigFilePath) } return nil }, diff --git a/cmd/login_influxdb.go b/cmd/login_influxdb.go index 15b550278203..8fc15851ebb5 100644 --- a/cmd/login_influxdb.go +++ b/cmd/login_influxdb.go @@ -9,12 +9,13 @@ import ( "golang.org/x/term" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/output/influxdb" "go.k6.io/k6/ui/console/form" ) //nolint:funlen -func getCmdLoginInfluxDB(globalState *globalState) *cobra.Command { +func getCmdLoginInfluxDB(gs *state.GlobalState) *cobra.Command { // loginInfluxDBCommand represents the 'login influxdb' command loginInfluxDBCommand := &cobra.Command{ Use: "influxdb [uri]", @@ -24,7 +25,7 @@ func getCmdLoginInfluxDB(globalState *globalState) *cobra.Command { This will set the default server used when just "-o influxdb" is passed.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - config, err := readDiskConfig(globalState) + config, err := readDiskConfig(gs) if err != nil { return err } @@ -70,9 +71,9 @@ This will set the default server used when just "-o influxdb" is passed.`, }, } if !term.IsTerminal(int(syscall.Stdin)) { //nolint:unconvert - globalState.logger.Warn("Stdin is not a terminal, falling back to plain text input") + gs.Logger.Warn("Stdin is not a terminal, falling back to plain text input") } - vals, err := f.Run(globalState.console.Stdin, globalState.console.Stdout) + vals, err := f.Run(gs.Console.Stdin, gs.Console.Stdout) if err != nil { return err } @@ -97,7 +98,7 @@ This will set the default server used when just "-o influxdb" is passed.`, if err != nil { return err } - return writeDiskConfig(globalState, config) + return writeDiskConfig(gs, config) }, } return loginInfluxDBCommand diff --git a/cmd/outputs.go b/cmd/outputs.go index 9c184984a7af..68975f596389 100644 --- a/cmd/outputs.go +++ b/cmd/outputs.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/ext" "go.k6.io/k6/lib" "go.k6.io/k6/output" @@ -68,7 +69,7 @@ func getPossibleIDList(constrs map[string]output.Constructor) string { } func createOutputs( - gs *globalState, test *loadedAndConfiguredTest, executionPlan []lib.ExecutionStep, + gs *state.GlobalState, test *loadedAndConfiguredTest, executionPlan []lib.ExecutionStep, ) ([]output.Output, error) { outputConstructors, err := getAllOutputConstructors() if err != nil { @@ -76,11 +77,11 @@ func createOutputs( } baseParams := output.Params{ ScriptPath: test.source.URL, - Logger: gs.logger, - Environment: gs.envVars, - StdOut: gs.console.Stdout, - StdErr: gs.console.Stderr, - FS: gs.fs, + Logger: gs.Logger, + Environment: gs.Env, + StdOut: gs.Console.Stdout, + StdErr: gs.Console.Stderr, + FS: gs.FS, ScriptOptions: test.derivedConfig.Options, RuntimeOptions: test.preInitState.RuntimeOptions, ExecutionPlan: executionPlan, diff --git a/cmd/panic_integration_test.go b/cmd/panic_integration_test.go index 19b17f4d0bfa..2864d96c693d 100644 --- a/cmd/panic_integration_test.go +++ b/cmd/panic_integration_test.go @@ -10,6 +10,7 @@ import ( "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/js/modules" "go.k6.io/k6/lib/testutils" @@ -84,14 +85,14 @@ func TestRunScriptPanicsErrorsAndAbort(t *testing.T) { t.Parallel() testFilename := "script.js" - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, testFilename), []byte(tc.testScript), 0o644)) - testState.args = []string{"k6", "run", testFilename} + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, testFilename), []byte(tc.testScript), 0o644)) + ts.CmdArgs = []string{"k6", "run", testFilename} - testState.expectedExitCode = int(exitcodes.ScriptAborted) - newRootCommand(testState.globalState).execute() + ts.ExpectedExitCode = int(exitcodes.ScriptAborted) + newRootCommand(ts.GlobalState).execute() - logs := testState.loggerHook.Drain() + logs := ts.LoggerHook.Drain() assert.True(t, testutils.LogContains(logs, logrus.ErrorLevel, tc.expectedLogMessage)) assert.False(t, testutils.LogContains(logs, logrus.InfoLevel, "lorem ipsum")) diff --git a/cmd/pause.go b/cmd/pause.go index 599d3cfd917e..f24307652ffb 100644 --- a/cmd/pause.go +++ b/cmd/pause.go @@ -6,9 +6,10 @@ import ( v1 "go.k6.io/k6/api/v1" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdPause(globalState *globalState) *cobra.Command { +func getCmdPause(gs *state.GlobalState) *cobra.Command { // pauseCmd represents the pause command pauseCmd := &cobra.Command{ Use: "pause", @@ -17,18 +18,18 @@ func getCmdPause(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.SetStatus(globalState.ctx, v1.Status{ + status, err := c.SetStatus(gs.Ctx, v1.Status{ Paused: null.BoolFrom(true), }) if err != nil { return err } - return globalState.console.PrintYAML(status) + return gs.Console.PrintYAML(status) }, } return pauseCmd diff --git a/cmd/resume.go b/cmd/resume.go index 998096828200..baee17e20ce7 100644 --- a/cmd/resume.go +++ b/cmd/resume.go @@ -6,9 +6,10 @@ import ( v1 "go.k6.io/k6/api/v1" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdResume(globalState *globalState) *cobra.Command { +func getCmdResume(gs *state.GlobalState) *cobra.Command { // resumeCmd represents the resume command resumeCmd := &cobra.Command{ Use: "resume", @@ -17,18 +18,18 @@ func getCmdResume(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.SetStatus(globalState.ctx, v1.Status{ + status, err := c.SetStatus(gs.Ctx, v1.Status{ Paused: null.BoolFrom(false), }) if err != nil { return err } - return globalState.console.PrintYAML(status) + return gs.Console.PrintYAML(status) }, } return resumeCmd diff --git a/cmd/root.go b/cmd/root.go index 1adda132ab70..91e6f34441d6 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -6,160 +6,32 @@ import ( "fmt" "io/ioutil" stdlog "log" - "os" - "os/signal" - "path/filepath" "strconv" "strings" "time" "github.com/sirupsen/logrus" - "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/pflag" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/lib/consts" "go.k6.io/k6/log" - "go.k6.io/k6/ui/console" ) -const ( - defaultConfigFileName = "config.json" - waitRemoteLoggerTimeout = time.Second * 5 -) - -// globalFlags contains global config values that apply for all k6 sub-commands. -type globalFlags struct { - configFilePath string - quiet bool - noColor bool - address string - logOutput string - logFormat string - verbose bool -} - -// globalState contains the globalFlags and accessors for most of the global -// process-external state like CLI arguments, env vars, standard input, output -// and error, etc. In practice, most of it is normally accessed through the `os` -// package from the Go stdlib. -// -// We group them here so we can prevent direct access to them from the rest of -// the k6 codebase. This gives us the ability to mock them and have robust and -// easy-to-write integration-like tests to check the k6 end-to-end behavior in -// any simulated conditions. -// -// `newGlobalState()` returns a globalState object with the real `os` -// parameters, while `newGlobalTestState()` can be used in tests to create -// simulated environments. -type globalState struct { - ctx context.Context - - fs afero.Fs - getwd func() (string, error) - args []string - envVars map[string]string - - defaultFlags, flags globalFlags - - console *console.Console - - osExit func(int) - signalNotify func(chan<- os.Signal, ...os.Signal) - signalStop func(chan<- os.Signal) - - logger *logrus.Logger -} - -// Ideally, this should be the only function in the whole codebase where we use -// global variables and functions from the os package. Anywhere else, things -// like os.Stdout, os.Stderr, os.Stdin, os.Getenv(), etc. should be removed and -// the respective properties of globalState used instead. -func newGlobalState(ctx context.Context) *globalState { - var logger *logrus.Logger - confDir, err := os.UserConfigDir() - if err != nil { - // The logger is initialized in the Console constructor, so defer - // logging of this error. - defer func() { - logger.WithError(err).Warn("could not get config directory") - }() - confDir = ".config" - } - - env := buildEnvMap(os.Environ()) - defaultFlags := getDefaultFlags(confDir) - flags := getFlags(defaultFlags, env) - - signalNotify := signal.Notify - signalStop := signal.Stop - - cons := console.New( - os.Stdout, os.Stderr, os.Stdin, - !flags.noColor, env["TERM"], signalNotify, signalStop) - logger = cons.GetLogger() - - return &globalState{ - ctx: ctx, - fs: afero.NewOsFs(), - getwd: os.Getwd, - args: append(make([]string, 0, len(os.Args)), os.Args...), // copy - envVars: env, - defaultFlags: defaultFlags, - flags: flags, - console: cons, - osExit: os.Exit, - signalNotify: signal.Notify, - signalStop: signal.Stop, - logger: logger, - } -} - -func getDefaultFlags(homeFolder string) globalFlags { - return globalFlags{ - address: "localhost:6565", - configFilePath: filepath.Join(homeFolder, "loadimpact", "k6", defaultConfigFileName), - logOutput: "stderr", - } -} - -func getFlags(defaultFlags globalFlags, env map[string]string) globalFlags { - result := defaultFlags - - // TODO: add env vars for the rest of the values (after adjusting - // rootCmdPersistentFlagSet(), of course) - - if val, ok := env["K6_CONFIG"]; ok { - result.configFilePath = val - } - if val, ok := env["K6_LOG_OUTPUT"]; ok { - result.logOutput = val - } - if val, ok := env["K6_LOG_FORMAT"]; ok { - result.logFormat = val - } - if env["K6_NO_COLOR"] != "" { - result.noColor = true - } - // Support https://no-color.org/, even an empty value should disable the - // color output from k6. - if _, ok := env["NO_COLOR"]; ok { - result.noColor = true - } - return result -} +const waitRemoteLoggerTimeout = time.Second * 5 // This is to keep all fields needed for the main/root k6 command type rootCommand struct { - globalState *globalState + globalState *state.GlobalState cmd *cobra.Command loggerStopped <-chan struct{} loggerIsRemote bool } -func newRootCommand(gs *globalState) *rootCommand { +func newRootCommand(gs *state.GlobalState) *rootCommand { c := &rootCommand{ globalState: gs, } @@ -167,19 +39,19 @@ func newRootCommand(gs *globalState) *rootCommand { rootCmd := &cobra.Command{ Use: "k6", Short: "a next-generation load generator", - Long: "\n" + gs.console.Banner(), + Long: "\n" + gs.Console.Banner(), SilenceUsage: true, SilenceErrors: true, PersistentPreRunE: c.persistentPreRunE, } rootCmd.PersistentFlags().AddFlagSet(rootCmdPersistentFlagSet(gs)) - rootCmd.SetArgs(gs.args[1:]) - rootCmd.SetOut(gs.console.Stdout) - rootCmd.SetErr(gs.console.Stderr) // TODO: use gs.logger.WriterLevel(logrus.ErrorLevel)? - rootCmd.SetIn(gs.console.Stdin) + rootCmd.SetArgs(gs.CmdArgs[1:]) + rootCmd.SetOut(gs.Console.Stdout) + rootCmd.SetErr(gs.Console.Stderr) // TODO: use gs.logger.WriterLevel(logrus.ErrorLevel)? + rootCmd.SetIn(gs.Console.Stdin) - subCommands := []func(*globalState) *cobra.Command{ + subCommands := []func(*state.GlobalState) *cobra.Command{ getCmdArchive, getCmdCloud, getCmdConvert, getCmdInspect, getCmdLogin, getCmdPause, getCmdResume, getCmdScale, getCmdRun, getCmdStats, getCmdStatus, getCmdVersion, @@ -206,15 +78,15 @@ func (c *rootCommand) persistentPreRunE(cmd *cobra.Command, args []string) error c.loggerIsRemote = true } - stdlog.SetOutput(c.globalState.logger.Writer()) - c.globalState.logger.Debugf("k6 version: v%s", consts.FullVersion()) + stdlog.SetOutput(c.globalState.Logger.Writer()) + c.globalState.Logger.Debugf("k6 version: v%s", consts.FullVersion()) return nil } func (c *rootCommand) execute() { - ctx, cancel := context.WithCancel(c.globalState.ctx) + ctx, cancel := context.WithCancel(c.globalState.Ctx) defer cancel() - c.globalState.ctx = ctx + c.globalState.Ctx = ctx err := c.cmd.Execute() if err == nil { @@ -242,21 +114,19 @@ func (c *rootCommand) execute() { fields["hint"] = herr.Hint() } - c.globalState.logger.WithFields(fields).Error(errText) + c.globalState.Logger.WithFields(fields).Error(errText) if c.loggerIsRemote { - c.globalState.logger.WithFields(fields).Error(errText) + c.globalState.Logger.WithFields(fields).Error(errText) cancel() c.waitRemoteLogger() } - c.globalState.osExit(exitCode) + c.globalState.OSExit(exitCode) } // Execute adds all child commands to the root command sets flags appropriately. // This is called by main.main(). It only needs to happen once to the rootCmd. -func Execute() { - gs := newGlobalState(context.Background()) - +func Execute(gs *state.GlobalState) { newRootCommand(gs).execute() } @@ -265,49 +135,49 @@ func (c *rootCommand) waitRemoteLogger() { select { case <-c.loggerStopped: case <-time.After(waitRemoteLoggerTimeout): - c.globalState.logger.Errorf("Remote logger didn't stop in %s", waitRemoteLoggerTimeout) + c.globalState.Logger.Errorf("Remote logger didn't stop in %s", waitRemoteLoggerTimeout) } } } -func rootCmdPersistentFlagSet(gs *globalState) *pflag.FlagSet { +func rootCmdPersistentFlagSet(gs *state.GlobalState) *pflag.FlagSet { flags := pflag.NewFlagSet("", pflag.ContinueOnError) // TODO: refactor this config, the default value management with pflag is // simply terrible... :/ // - // We need to use `gs.flags.` both as the destination and as + // We need to use `gs.Flags.` both as the destination and as // the value here, since the config values could have already been set by // their respective environment variables. However, we then also have to // explicitly set the DefValue to the respective default value from - // `gs.defaultFlags.`, so that the `k6 --help` message is + // `gs.DefaultFlags.`, so that the `k6 --help` message is // not messed up... - flags.StringVar(&gs.flags.logOutput, "log-output", gs.flags.logOutput, + flags.StringVar(&gs.Flags.LogOutput, "log-output", gs.Flags.LogOutput, "change the output for k6 logs, possible values are stderr,stdout,none,loki[=host:port],file[=./path.fileformat]") - flags.Lookup("log-output").DefValue = gs.defaultFlags.logOutput + flags.Lookup("log-output").DefValue = gs.DefaultFlags.LogOutput - flags.StringVar(&gs.flags.logFormat, "logformat", gs.flags.logFormat, "log output format") + flags.StringVar(&gs.Flags.LogFormat, "logformat", gs.Flags.LogFormat, "log output format") oldLogFormat := flags.Lookup("logformat") oldLogFormat.Hidden = true oldLogFormat.Deprecated = "log-format" - oldLogFormat.DefValue = gs.defaultFlags.logFormat - flags.StringVar(&gs.flags.logFormat, "log-format", gs.flags.logFormat, "log output format") - flags.Lookup("log-format").DefValue = gs.defaultFlags.logFormat + oldLogFormat.DefValue = gs.DefaultFlags.LogFormat + flags.StringVar(&gs.Flags.LogFormat, "log-format", gs.Flags.LogFormat, "log output format") + flags.Lookup("log-format").DefValue = gs.DefaultFlags.LogFormat - flags.StringVarP(&gs.flags.configFilePath, "config", "c", gs.flags.configFilePath, "JSON config file") + flags.StringVarP(&gs.Flags.ConfigFilePath, "config", "c", gs.Flags.ConfigFilePath, "JSON config file") // And we also need to explicitly set the default value for the usage message here, so things // like `K6_CONFIG="blah" k6 run -h` don't produce a weird usage message - flags.Lookup("config").DefValue = gs.defaultFlags.configFilePath + flags.Lookup("config").DefValue = gs.DefaultFlags.ConfigFilePath must(cobra.MarkFlagFilename(flags, "config")) - flags.BoolVar(&gs.flags.noColor, "no-color", gs.flags.noColor, "disable colored output") - flags.Lookup("no-color").DefValue = strconv.FormatBool(gs.defaultFlags.noColor) + flags.BoolVar(&gs.Flags.NoColor, "no-color", gs.Flags.NoColor, "disable colored output") + flags.Lookup("no-color").DefValue = strconv.FormatBool(gs.DefaultFlags.NoColor) // TODO: support configuring these through environment variables as well? // either with croconf or through the hack above... - flags.BoolVarP(&gs.flags.verbose, "verbose", "v", gs.defaultFlags.verbose, "enable verbose logging") - flags.BoolVarP(&gs.flags.quiet, "quiet", "q", gs.defaultFlags.quiet, "disable progress updates") - flags.StringVarP(&gs.flags.address, "address", "a", gs.defaultFlags.address, "address for the REST API server") + flags.BoolVarP(&gs.Flags.Verbose, "verbose", "v", gs.DefaultFlags.Verbose, "enable verbose logging") + flags.BoolVarP(&gs.Flags.Quiet, "quiet", "q", gs.DefaultFlags.Quiet, "disable progress updates") + flags.StringVarP(&gs.Flags.Address, "address", "a", gs.DefaultFlags.Address, "address for the REST API server") return flags } @@ -327,54 +197,54 @@ func (c *rootCommand) setupLoggers() (<-chan struct{}, error) { ch := make(chan struct{}) close(ch) - if c.globalState.flags.verbose { - c.globalState.logger.SetLevel(logrus.DebugLevel) + if c.globalState.Flags.Verbose { + c.globalState.Logger.SetLevel(logrus.DebugLevel) } - switch line := c.globalState.flags.logOutput; { + switch line := c.globalState.Flags.LogOutput; { case line == "stderr": - c.globalState.logger.SetOutput(c.globalState.console.Stderr) + c.globalState.Logger.SetOutput(c.globalState.Console.Stderr) case line == "stdout": - c.globalState.logger.SetOutput(c.globalState.console.Stdout) + c.globalState.Logger.SetOutput(c.globalState.Console.Stdout) case line == "none": - c.globalState.logger.SetOutput(ioutil.Discard) + c.globalState.Logger.SetOutput(ioutil.Discard) case strings.HasPrefix(line, "loki"): ch = make(chan struct{}) // TODO: refactor, get it from the constructor - hook, err := log.LokiFromConfigLine(c.globalState.ctx, c.globalState.logger, line, ch) + hook, err := log.LokiFromConfigLine(c.globalState.Ctx, c.globalState.Logger, line, ch) if err != nil { return nil, err } - c.globalState.logger.AddHook(hook) - c.globalState.logger.SetOutput(ioutil.Discard) // don't output to anywhere else - c.globalState.flags.logFormat = "raw" + c.globalState.Logger.AddHook(hook) + c.globalState.Logger.SetOutput(ioutil.Discard) // don't output to anywhere else + c.globalState.Flags.LogFormat = "raw" case strings.HasPrefix(line, "file"): ch = make(chan struct{}) // TODO: refactor, get it from the constructor hook, err := log.FileHookFromConfigLine( - c.globalState.ctx, c.globalState.fs, c.globalState.getwd, - c.globalState.logger, line, ch, + c.globalState.Ctx, c.globalState.FS, c.globalState.Getwd, + c.globalState.Logger, line, ch, ) if err != nil { return nil, err } - c.globalState.logger.AddHook(hook) - c.globalState.logger.SetOutput(ioutil.Discard) + c.globalState.Logger.AddHook(hook) + c.globalState.Logger.SetOutput(ioutil.Discard) default: return nil, fmt.Errorf("unsupported log output '%s'", line) } - switch c.globalState.flags.logFormat { + switch c.globalState.Flags.LogFormat { case "raw": - c.globalState.logger.SetFormatter(&RawFormatter{}) - c.globalState.logger.Debug("Logger format: RAW") + c.globalState.Logger.SetFormatter(&RawFormatter{}) + c.globalState.Logger.Debug("Logger format: RAW") case "json": - c.globalState.logger.SetFormatter(&logrus.JSONFormatter{}) - c.globalState.logger.Debug("Logger format: JSON") + c.globalState.Logger.SetFormatter(&logrus.JSONFormatter{}) + c.globalState.Logger.Debug("Logger format: JSON") default: - c.globalState.logger.Debug("Logger format: TEXT") + c.globalState.Logger.Debug("Logger format: TEXT") } return ch, nil } diff --git a/cmd/root_test.go b/cmd/root_test.go deleted file mode 100644 index 43a5dfbae469..000000000000 --- a/cmd/root_test.go +++ /dev/null @@ -1,240 +0,0 @@ -package cmd - -import ( - "bytes" - "context" - "fmt" - "io" - "net" - "net/http" - "os" - "os/signal" - "runtime" - "strconv" - "sync" - "sync/atomic" - "testing" - - "github.com/sirupsen/logrus" - "github.com/spf13/afero" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.k6.io/k6/lib/testutils" - "go.k6.io/k6/ui/console" -) - -type blockingTransport struct { - fallback http.RoundTripper - forbiddenHosts map[string]bool - counter uint32 -} - -func (bt *blockingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - host := req.URL.Hostname() - if bt.forbiddenHosts[host] { - atomic.AddUint32(&bt.counter, 1) - panic(fmt.Errorf("trying to make forbidden request to %s during test", host)) - } - return bt.fallback.RoundTrip(req) -} - -func TestMain(m *testing.M) { - exitCode := 1 // error out by default - defer func() { - os.Exit(exitCode) - }() - - bt := &blockingTransport{ - fallback: http.DefaultTransport, - forbiddenHosts: map[string]bool{ - "ingest.k6.io": true, - "cloudlogs.k6.io": true, - "app.k6.io": true, - "reports.k6.io": true, - }, - } - http.DefaultTransport = bt - defer func() { - if bt.counter > 0 { - fmt.Printf("Expected blocking transport count to be 0 but was %d\n", bt.counter) //nolint:forbidigo - exitCode = 2 - } - }() - - // TODO: add https://github.com/uber-go/goleak - - exitCode = m.Run() -} - -type bufferStringer interface { - io.ReadWriter - fmt.Stringer - Bytes() []byte -} - -type globalTestState struct { - *globalState - cancel func() - - stdOut, stdErr bufferStringer - loggerHook *testutils.SimpleLogrusHook - - cwd string - - expectedExitCode int -} - -// A thread-safe buffer implementation. -type safeBuffer struct { - b bytes.Buffer - m sync.RWMutex -} - -func (b *safeBuffer) Read(p []byte) (n int, err error) { - b.m.RLock() - defer b.m.RUnlock() - return b.b.Read(p) -} - -func (b *safeBuffer) Write(p []byte) (n int, err error) { - b.m.Lock() - defer b.m.Unlock() - return b.b.Write(p) -} - -func (b *safeBuffer) String() string { - b.m.RLock() - defer b.m.RUnlock() - return b.b.String() -} - -func (b *safeBuffer) Bytes() []byte { - b.m.RLock() - defer b.m.RUnlock() - return b.b.Bytes() -} - -type testOSFileW struct { - io.Writer -} - -func (f *testOSFileW) Fd() uintptr { - return 0 -} - -type testOSFileR struct { - io.Reader -} - -func (f *testOSFileR) Fd() uintptr { - return 0 -} - -var portRangeStart uint64 = 6565 //nolint:gochecknoglobals - -func getFreeBindAddr(t *testing.T) string { - for i := 0; i < 100; i++ { - port := atomic.AddUint64(&portRangeStart, 1) - addr := net.JoinHostPort("localhost", strconv.FormatUint(port, 10)) - - listener, err := net.Listen("tcp", addr) - if err != nil { - continue // port was busy for some reason - } - defer func() { - assert.NoError(t, listener.Close()) - }() - return addr - } - t.Fatal("could not get a free port") - return "" -} - -func newGlobalTestState(t *testing.T) *globalTestState { - ctx, cancel := context.WithCancel(context.Background()) - t.Cleanup(cancel) - - fs := &afero.MemMapFs{} - cwd := "/test/" - if runtime.GOOS == "windows" { - cwd = "c:\\test\\" - } - require.NoError(t, fs.MkdirAll(cwd, 0o755)) - - logger := logrus.New() - logger.SetLevel(logrus.InfoLevel) - logger.Out = testutils.NewTestOutput(t) - hook := &testutils.SimpleLogrusHook{HookedLevels: logrus.AllLevels} - logger.AddHook(hook) - - ts := &globalTestState{ - cwd: cwd, - cancel: cancel, - loggerHook: hook, - stdOut: &safeBuffer{}, - stdErr: &safeBuffer{}, - } - - osExitCalled := false - defaultOsExitHandle := func(exitCode int) { - cancel() - osExitCalled = true - assert.Equal(t, ts.expectedExitCode, exitCode) - } - - t.Cleanup(func() { - if ts.expectedExitCode > 0 { - // Ensure that, if we expected to receive an error, our `os.Exit()` mock - // function was actually called. - assert.Truef(t, osExitCalled, "expected exit code %d, but the os.Exit() mock was not called", ts.expectedExitCode) - } - }) - - defaultFlags := getDefaultFlags(".config") - defaultFlags.address = getFreeBindAddr(t) - - cons := console.New( - &testOSFileW{ts.stdOut}, &testOSFileW{ts.stdErr}, - &testOSFileR{&safeBuffer{}}, false, "", signal.Notify, signal.Stop) - cons.SetLogger(logger) - - ts.globalState = &globalState{ - ctx: ctx, - fs: fs, - console: cons, - getwd: func() (string, error) { return ts.cwd, nil }, - args: []string{}, - envVars: map[string]string{"K6_NO_USAGE_REPORT": "true"}, - defaultFlags: defaultFlags, - flags: defaultFlags, - osExit: defaultOsExitHandle, - signalNotify: signal.Notify, - signalStop: signal.Stop, - logger: logger, - } - return ts -} - -func TestDeprecatedOptionWarning(t *testing.T) { - t.Parallel() - - ts := newGlobalTestState(t) - ts.args = []string{"k6", "--logformat", "json", "run", "-"} - ts.console.Stdin = &testOSFileR{bytes.NewBuffer([]byte(` - console.log('foo'); - export default function() { console.log('bar'); }; - `))} - - newRootCommand(ts.globalState).execute() - - logMsgs := ts.loggerHook.Drain() - assert.True(t, testutils.LogContains(logMsgs, logrus.InfoLevel, "foo")) - assert.True(t, testutils.LogContains(logMsgs, logrus.InfoLevel, "bar")) - assert.Contains(t, ts.stdErr.String(), `"level":"info","msg":"foo","source":"console"`) - assert.Contains(t, ts.stdErr.String(), `"level":"info","msg":"bar","source":"console"`) - - // TODO: after we get rid of cobra, actually emit this message to stderr - // and, ideally, through the log, not just print it... - assert.False(t, testutils.LogContains(logMsgs, logrus.InfoLevel, "logformat")) - assert.Contains(t, ts.stdOut.String(), `--logformat has been deprecated`) -} diff --git a/cmd/run.go b/cmd/run.go index 380fb4d4790f..038a4f1bc1d9 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -18,6 +18,7 @@ import ( "github.com/spf13/pflag" "go.k6.io/k6/api" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/core" "go.k6.io/k6/core/local" "go.k6.io/k6/errext" @@ -30,7 +31,7 @@ import ( // cmdRun handles the `k6 run` sub-command type cmdRun struct { - gs *globalState + gs *state.GlobalState } // TODO: split apart some more @@ -61,7 +62,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { // - The globalCtx is cancelled only after we're completely done with the // test execution and any --linger has been cleared, so that the Engine // can start winding down its metrics processing. - globalCtx, globalCancel := context.WithCancel(c.gs.ctx) + globalCtx, globalCancel := context.WithCancel(c.gs.Ctx) defer globalCancel() lingerCtx, lingerCancel := context.WithCancel(globalCtx) defer lingerCancel() @@ -80,7 +81,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { progressCtx, progressCancel := context.WithCancel(globalCtx) defer progressCancel() progressBarWG := &sync.WaitGroup{} - if !c.gs.flags.quiet { + if !c.gs.Flags.Quiet { progressBarWG.Add(1) // This is manually triggered after the Engine's Run() has completed, @@ -93,7 +94,7 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { for _, s := range execScheduler.GetExecutors() { pbs = append(pbs, s.GetProgress()) } - c.gs.console.ShowProgress(progressCtx, pbs) + c.gs.Console.ShowProgress(progressCtx, pbs) }() } @@ -116,16 +117,16 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { } // Spin up the REST API server, if not disabled. - if c.gs.flags.address != "" { + if c.gs.Flags.Address != "" { initBar.Modify(pb.WithConstProgress(0, "Init API server")) go func() { - logger.Debugf("Starting the REST API server on %s", c.gs.flags.address) + logger.Debugf("Starting the REST API server on %s", c.gs.Flags.Address) // TODO: send the ExecutionState and MetricsEngine instead of the Engine - if aerr := api.ListenAndServe(c.gs.flags.address, engine, logger); aerr != nil { + if aerr := api.ListenAndServe(c.gs.Flags.Address, engine, logger); aerr != nil { // Only exit k6 if the user has explicitly set the REST API address if cmd.Flags().Lookup("address").Changed { logger.WithError(aerr).Error("Error from API server") - c.gs.osExit(int(exitcodes.CannotStartRESTAPI)) + c.gs.OSExit(int(exitcodes.CannotStartRESTAPI)) } else { logger.WithError(aerr).Warn("Error from API server") } @@ -143,13 +144,13 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { defer engine.OutputManager.StopOutputs() execDesc := getExecutionDescription( - c.gs.console.ApplyTheme, "local", args[0], "", conf, + c.gs.Console.ApplyTheme, "local", args[0], "", conf, execScheduler.GetState().ExecutionTuple, executionPlan, outputs, ) - if c.gs.flags.quiet { - c.gs.logger.Debug(execDesc) + if c.gs.Flags.Quiet { + c.gs.Logger.Debug(execDesc) } else { - c.gs.console.Print(execDesc) + c.gs.Console.Print(execDesc) } // Trap Interrupts, SIGINTs and SIGTERMs. @@ -216,15 +217,15 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { Metrics: engine.MetricsEngine.ObservedMetrics, RootGroup: execScheduler.GetRunner().GetDefaultGroup(), TestRunDuration: executionState.GetCurrentTestRunDuration(), - NoColor: c.gs.flags.noColor, + NoColor: c.gs.Flags.NoColor, UIState: lib.UIState{ - IsStdOutTTY: c.gs.console.IsTTY, - IsStdErrTTY: c.gs.console.IsTTY, + IsStdOutTTY: c.gs.Console.IsTTY, + IsStdErrTTY: c.gs.Console.IsTTY, }, }) engine.MetricsEngine.MetricsLock.Unlock() if hsErr == nil { - hsErr = handleSummaryResult(c.gs.fs, c.gs.console.Stdout, c.gs.console.Stderr, summaryResult) + hsErr = handleSummaryResult(c.gs.FS, c.gs.Console.Stdout, c.gs.Console.Stderr, summaryResult) } if hsErr != nil { logger.WithError(hsErr).Error("failed to handle the end-of-test summary") @@ -237,8 +238,8 @@ func (c *cmdRun) run(cmd *cobra.Command, args []string) error { // do nothing, we were interrupted by Ctrl+C already default: logger.Debug("Linger set; waiting for Ctrl+C...") - if !c.gs.flags.quiet { - c.gs.console.Print("Linger set; waiting for Ctrl+C...") + if !c.gs.Flags.Quiet { + c.gs.Console.Printf("Linger set; waiting for Ctrl+C...") } <-lingerCtx.Done() logger.Debug("Ctrl+C received, exiting...") @@ -271,7 +272,7 @@ func (c *cmdRun) flagSet() *pflag.FlagSet { return flags } -func getCmdRun(gs *globalState) *cobra.Command { +func getCmdRun(gs *state.GlobalState) *cobra.Command { c := &cmdRun{ gs: gs, } diff --git a/cmd/run_test.go b/cmd/run_test.go index f7b041f60c92..8b4090d9ee3c 100644 --- a/cmd/run_test.go +++ b/cmd/run_test.go @@ -18,6 +18,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/lib/fsext" @@ -200,14 +201,14 @@ func TestRunScriptErrorsAndAbort(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, tc.testFilename), testScript, 0o644)) - testState.args = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) + ts.CmdArgs = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) - testState.expectedExitCode = int(tc.expExitCode) - newRootCommand(testState.globalState).execute() + ts.ExpectedExitCode = int(tc.expExitCode) + newRootCommand(ts.GlobalState).execute() - logs := testState.loggerHook.Drain() + logs := ts.LoggerHook.Drain() if tc.expErr != "" { assert.True(t, testutils.LogContains(logs, logrus.ErrorLevel, tc.expErr)) @@ -255,12 +256,12 @@ func TestInvalidOptionsThresholdErrExitCode(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, tc.testFilename), testScript, 0o644)) - testState.args = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) + ts.CmdArgs = append([]string{"k6", "run", tc.testFilename}, tc.extraArgs...) - testState.expectedExitCode = int(tc.expExitCode) - newRootCommand(testState.globalState).execute() + ts.ExpectedExitCode = int(tc.expExitCode) + newRootCommand(ts.GlobalState).execute() }) } } @@ -305,20 +306,20 @@ func TestThresholdsRuntimeBehavior(t *testing.T) { testScript, err := ioutil.ReadFile(path.Join("testdata", tc.testFilename)) require.NoError(t, err) - testState := newGlobalTestState(t) - require.NoError(t, afero.WriteFile(testState.fs, filepath.Join(testState.cwd, tc.testFilename), testScript, 0o644)) + ts := state.NewGlobalTestState(t) + require.NoError(t, afero.WriteFile(ts.FS, filepath.Join(ts.Cwd, tc.testFilename), testScript, 0o644)) - testState.args = []string{"k6", "run", tc.testFilename} - testState.expectedExitCode = int(tc.expExitCode) - newRootCommand(testState.globalState).execute() + ts.CmdArgs = []string{"k6", "run", tc.testFilename} + ts.ExpectedExitCode = int(tc.expExitCode) + newRootCommand(ts.GlobalState).execute() if tc.expStdoutContains != "" { - assert.Contains(t, testState.stdOut.String(), tc.expStdoutContains) + assert.Contains(t, ts.Stdout.String(), tc.expStdoutContains) } if tc.expStdoutNotContains != "" { - log.Println(testState.stdOut.String()) - assert.NotContains(t, testState.stdOut.String(), tc.expStdoutNotContains) + log.Println(ts.Stdout.String()) + assert.NotContains(t, ts.Stdout.String(), tc.expStdoutNotContains) } }) } diff --git a/cmd/runtime_options_test.go b/cmd/runtime_options_test.go index c339eabc5f44..81b6ebe949ff 100644 --- a/cmd/runtime_options_test.go +++ b/cmd/runtime_options_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/guregu/null.v3" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/lib" "go.k6.io/k6/loader" "go.k6.io/k6/metrics" @@ -58,21 +59,21 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { fs := afero.NewMemMapFs() require.NoError(t, afero.WriteFile(fs, "/script.js", jsCode.Bytes(), 0o644)) - ts := newGlobalTestState(t) // TODO: move upwards, make this into an almost full integration test + ts := state.NewGlobalTestState(t) // TODO: move upwards, make this into an almost full integration test registry := metrics.NewRegistry() test := &loadedTest{ sourceRootPath: "script.js", source: &loader.SourceData{Data: jsCode.Bytes(), URL: &url.URL{Path: "/script.js", Scheme: "file"}}, fileSystems: map[string]afero.Fs{"file": fs}, preInitState: &lib.TestPreInitState{ - Logger: ts.logger, + Logger: ts.Logger, RuntimeOptions: rtOpts, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), }, } - require.NoError(t, test.initializeFirstRunner(ts.globalState)) + require.NoError(t, test.initializeFirstRunner(ts.GlobalState)) archive := test.initRunner.MakeArchive() archiveBuf := &bytes.Buffer{} @@ -84,7 +85,7 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { source: &loader.SourceData{Data: archiveBuf.Bytes(), URL: &url.URL{Path: "/script.tar", Scheme: "file"}}, fileSystems: map[string]afero.Fs{"file": fs}, preInitState: &lib.TestPreInitState{ - Logger: ts.logger, + Logger: ts.Logger, RuntimeOptions: rtOpts, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), @@ -93,11 +94,11 @@ func testRuntimeOptionsCase(t *testing.T, tc runtimeOptionsTestCase) { } archTest := getRunnerErr(lib.RuntimeOptions{}) - require.NoError(t, archTest.initializeFirstRunner(ts.globalState)) + require.NoError(t, archTest.initializeFirstRunner(ts.GlobalState)) for key, val := range tc.expRTOpts.Env { archTest = getRunnerErr(lib.RuntimeOptions{Env: map[string]string{key: "almost " + val}}) - require.NoError(t, archTest.initializeFirstRunner(ts.globalState)) + require.NoError(t, archTest.initializeFirstRunner(ts.GlobalState)) assert.Equal(t, archTest.initRunner.MakeArchive().Env[key], "almost "+val) } } diff --git a/cmd/scale.go b/cmd/scale.go index 4ff475d0e7b9..c5fa377c34d3 100644 --- a/cmd/scale.go +++ b/cmd/scale.go @@ -7,9 +7,10 @@ import ( v1 "go.k6.io/k6/api/v1" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdScale(globalState *globalState) *cobra.Command { +func getCmdScale(gs *state.GlobalState) *cobra.Command { // scaleCmd represents the scale command scaleCmd := &cobra.Command{ Use: "scale", @@ -24,16 +25,16 @@ func getCmdScale(globalState *globalState) *cobra.Command { return errors.New("Specify either -u/--vus or -m/--max") //nolint:golint,stylecheck } - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.SetStatus(globalState.ctx, v1.Status{VUs: vus, VUsMax: max}) + status, err := c.SetStatus(gs.Ctx, v1.Status{VUs: vus, VUsMax: max}) if err != nil { return err } - return globalState.console.PrintYAML(status) + return gs.Console.PrintYAML(status) }, } diff --git a/cmd/state/buffer.go b/cmd/state/buffer.go new file mode 100644 index 000000000000..d09d3f17df29 --- /dev/null +++ b/cmd/state/buffer.go @@ -0,0 +1,49 @@ +package state + +import ( + "bytes" + "fmt" + "io" + "sync" +) + +// BufferStringer is an interface for mocking standard streams. +type BufferStringer interface { + io.ReadWriter + fmt.Stringer + Bytes() []byte +} + +// SafeBuffer implements a thread-safe read/write buffer, that allows reading +// its current data. It's typically useful for tests. +type SafeBuffer struct { + b bytes.Buffer + m sync.RWMutex +} + +var _ BufferStringer = &SafeBuffer{} + +func (b *SafeBuffer) Read(p []byte) (n int, err error) { + b.m.RLock() + defer b.m.RUnlock() + return b.b.Read(p) +} + +func (b *SafeBuffer) Write(p []byte) (n int, err error) { + b.m.Lock() + defer b.m.Unlock() + return b.b.Write(p) +} + +func (b *SafeBuffer) String() string { + b.m.RLock() + defer b.m.RUnlock() + return b.b.String() +} + +// Bytes returns the unread bytes contained in the buffer. +func (b *SafeBuffer) Bytes() []byte { + b.m.RLock() + defer b.m.RUnlock() + return b.b.Bytes() +} diff --git a/cmd/state/doc.go b/cmd/state/doc.go new file mode 100644 index 000000000000..425dc09243da --- /dev/null +++ b/cmd/state/doc.go @@ -0,0 +1,4 @@ +// Package state contains the types and functionality used for keeping track of +// cmd-related values that are used globally throughout k6. It also exposes some +// related test types and helpers. +package state diff --git a/cmd/state/state.go b/cmd/state/state.go new file mode 100644 index 000000000000..ad821360da6b --- /dev/null +++ b/cmd/state/state.go @@ -0,0 +1,133 @@ +package state + +import ( + "context" + "os" + "os/signal" + "path/filepath" + + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + + "go.k6.io/k6/lib" + "go.k6.io/k6/ui/console" +) + +const defaultConfigFileName = "config.json" + +// GlobalState contains the GlobalFlags and accessors for most of the global +// process-external state like CLI arguments, env vars, standard input, output +// and error, etc. In practice, most of it is normally accessed through the `os` +// package from the Go stdlib. +// +// We group them here so we can prevent direct access to them from the rest of +// the k6 codebase. This gives us the ability to mock them and have robust and +// easy-to-write integration-like tests to check the k6 end-to-end behavior in +// any simulated conditions. +// +// `newGlobalState()` returns a globalState object with the real `os` +// parameters, while `newGlobalTestState()` can be used in tests to create +// simulated environments. +type GlobalState struct { + Ctx context.Context + FS afero.Fs + Getwd func() (string, error) + CmdArgs []string + Env map[string]string + DefaultFlags, Flags GlobalFlags + Console *console.Console + OSExit func(int) + SignalNotify func(chan<- os.Signal, ...os.Signal) + SignalStop func(chan<- os.Signal) + Logger *logrus.Logger +} + +// NewGlobalState returns a new GlobalState with the given ctx. +// Ideally, this should be the only function in the whole codebase where we use +// global variables and functions from the os package. Anywhere else, things +// like os.Stdout, os.Stderr, os.Stdin, os.Getenv(), etc. should be removed and +// the respective properties of globalState used instead. +func NewGlobalState(ctx context.Context) *GlobalState { + var logger *logrus.Logger + confDir, err := os.UserConfigDir() + if err != nil { + // The logger is initialized in the Console constructor, so defer + // logging of this error. + defer func() { + logger.WithError(err).Warn("could not get config directory") + }() + confDir = ".config" + } + + env := lib.BuildEnvMap(os.Environ()) + defaultFlags := GetDefaultFlags(confDir) + flags := getFlags(defaultFlags, env) + + signalNotify := signal.Notify + signalStop := signal.Stop + + cons := console.New(os.Stdout, os.Stderr, os.Stdin, + !flags.NoColor, env["TERM"], signalNotify, signalStop) + logger = cons.GetLogger() + + return &GlobalState{ + Ctx: ctx, + FS: afero.NewOsFs(), + Getwd: os.Getwd, + CmdArgs: os.Args, + Env: env, + DefaultFlags: defaultFlags, + Flags: flags, + Console: cons, + OSExit: os.Exit, + SignalNotify: signalNotify, + SignalStop: signalStop, + Logger: logger, + } +} + +// GlobalFlags contains global config values that apply for all k6 sub-commands. +type GlobalFlags struct { + ConfigFilePath string + Quiet bool + NoColor bool + Address string + LogOutput string + LogFormat string + Verbose bool +} + +// GetDefaultFlags returns the default global flags. +func GetDefaultFlags(homeDir string) GlobalFlags { + return GlobalFlags{ + Address: "localhost:6565", + ConfigFilePath: filepath.Join(homeDir, "loadimpact", "k6", defaultConfigFileName), + LogOutput: "stderr", + } +} + +func getFlags(defaultFlags GlobalFlags, env map[string]string) GlobalFlags { + result := defaultFlags + + // TODO: add env vars for the rest of the values (after adjusting + // rootCmdPersistentFlagSet(), of course) + + if val, ok := env["K6_CONFIG"]; ok { + result.ConfigFilePath = val + } + if val, ok := env["K6_LOG_OUTPUT"]; ok { + result.LogOutput = val + } + if val, ok := env["K6_LOG_FORMAT"]; ok { + result.LogFormat = val + } + if env["K6_NO_COLOR"] != "" { + result.NoColor = true + } + // Support https://no-color.org/, even an empty value should disable the + // color output from k6. + if _, ok := env["NO_COLOR"]; ok { + result.NoColor = true + } + return result +} diff --git a/cmd/state/test_state.go b/cmd/state/test_state.go new file mode 100644 index 000000000000..d0307d0ad4bc --- /dev/null +++ b/cmd/state/test_state.go @@ -0,0 +1,141 @@ +package state + +import ( + "context" + "io" + "net" + "os/signal" + "runtime" + "strconv" + "sync/atomic" + "testing" + + "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.k6.io/k6/lib/testutils" + "go.k6.io/k6/ui/console" +) + +// GlobalTestState is a wrapper around GlobalState for use in tests. +type GlobalTestState struct { + *GlobalState + Cancel func() + + Stdout, Stderr BufferStringer + LoggerHook *testutils.SimpleLogrusHook + + Cwd string + + ExpectedExitCode int +} + +// NewGlobalTestState returns an initialized GlobalTestState, mocking all +// GlobalState fields for use in tests. +func NewGlobalTestState(t *testing.T) *GlobalTestState { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + fs := &afero.MemMapFs{} + cwd := "/test/" // TODO: Make this relative to the test? + if runtime.GOOS == "windows" { + cwd = "c:\\test\\" + } + require.NoError(t, fs.MkdirAll(cwd, 0o755)) + + logger := logrus.New() + logger.SetLevel(logrus.InfoLevel) + logger.Out = testutils.NewTestOutput(t) + hook := &testutils.SimpleLogrusHook{HookedLevels: logrus.AllLevels} + logger.AddHook(hook) + + ts := &GlobalTestState{ + Cwd: cwd, + Cancel: cancel, + LoggerHook: hook, + Stdout: &SafeBuffer{}, + Stderr: &SafeBuffer{}, + } + + osExitCalled := false + defaultOsExitHandle := func(exitCode int) { + cancel() + osExitCalled = true + assert.Equal(t, ts.ExpectedExitCode, exitCode) + } + + t.Cleanup(func() { + if ts.ExpectedExitCode > 0 { + // Ensure that, if we expected to receive an error, our `os.Exit()` mock + // function was actually called. + assert.Truef(t, osExitCalled, "expected exit code %d, but the os.Exit() mock was not called", ts.ExpectedExitCode) + } + }) + + defaultFlags := GetDefaultFlags(".config") + defaultFlags.Address = getFreeBindAddr(t) + + cons := console.New( + &TestOSFileW{ts.Stdout}, &TestOSFileW{ts.Stderr}, + &TestOSFileR{&SafeBuffer{}}, false, "", signal.Notify, signal.Stop) + cons.SetLogger(logger) + + ts.GlobalState = &GlobalState{ + Ctx: ctx, + FS: fs, + Console: cons, + Getwd: func() (string, error) { return ts.Cwd, nil }, + CmdArgs: []string{}, + Env: map[string]string{"K6_NO_USAGE_REPORT": "true"}, + DefaultFlags: defaultFlags, + Flags: defaultFlags, + OSExit: defaultOsExitHandle, + SignalNotify: signal.Notify, + SignalStop: signal.Stop, + Logger: logger, + } + + return ts +} + +// TestOSFileW is the mock implementation of stdout/stderr. +type TestOSFileW struct { + io.Writer +} + +// Fd returns a mock file descriptor ID. +func (f *TestOSFileW) Fd() uintptr { + return 0 +} + +// TestOSFileR is the mock implementation of stdin. +type TestOSFileR struct { + io.Reader +} + +// Fd returns a mock file descriptor ID. +func (f *TestOSFileR) Fd() uintptr { + return 0 +} + +var portRangeStart uint64 = 6565 //nolint:gochecknoglobals + +func getFreeBindAddr(t *testing.T) string { + for i := 0; i < 100; i++ { + port := atomic.AddUint64(&portRangeStart, 1) + addr := net.JoinHostPort("localhost", strconv.FormatUint(port, 10)) + + listener, err := net.Listen("tcp", addr) + if err != nil { + continue // port was busy for some reason + } + defer func() { + assert.NoError(t, listener.Close()) + }() + return addr + } + + t.Fatal("could not get a free port") + return "" +} diff --git a/cmd/stats.go b/cmd/stats.go index 0a7601ebfd1e..78655e418398 100644 --- a/cmd/stats.go +++ b/cmd/stats.go @@ -4,9 +4,10 @@ import ( "github.com/spf13/cobra" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdStats(globalState *globalState) *cobra.Command { +func getCmdStats(gs *state.GlobalState) *cobra.Command { // statsCmd represents the stats command statsCmd := &cobra.Command{ Use: "stats", @@ -15,16 +16,16 @@ func getCmdStats(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - metrics, err := c.Metrics(globalState.ctx) + metrics, err := c.Metrics(gs.Ctx) if err != nil { return err } - return globalState.console.PrintYAML(metrics) + return gs.Console.PrintYAML(metrics) }, } return statsCmd diff --git a/cmd/status.go b/cmd/status.go index cb72cb971d81..2a275bce07eb 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -4,9 +4,10 @@ import ( "github.com/spf13/cobra" "go.k6.io/k6/api/v1/client" + "go.k6.io/k6/cmd/state" ) -func getCmdStatus(globalState *globalState) *cobra.Command { +func getCmdStatus(gs *state.GlobalState) *cobra.Command { // statusCmd represents the status command statusCmd := &cobra.Command{ Use: "status", @@ -15,16 +16,16 @@ func getCmdStatus(globalState *globalState) *cobra.Command { Use the global --address flag to specify the URL to the API server.`, RunE: func(cmd *cobra.Command, args []string) error { - c, err := client.New(globalState.flags.address) + c, err := client.New(gs.Flags.Address) if err != nil { return err } - status, err := c.Status(globalState.ctx) + status, err := c.Status(gs.Ctx) if err != nil { return err } - return globalState.console.PrintYAML(status) + return gs.Console.PrintYAML(status) }, } return statusCmd diff --git a/cmd/test_load.go b/cmd/test_load.go index 9657be13f92b..6e58ca78a570 100644 --- a/cmd/test_load.go +++ b/cmd/test_load.go @@ -14,6 +14,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/cobra" "github.com/spf13/pflag" + "go.k6.io/k6/cmd/state" "go.k6.io/k6/errext" "go.k6.io/k6/errext/exitcodes" "go.k6.io/k6/js" @@ -40,32 +41,32 @@ type loadedTest struct { keyLogger io.Closer } -func loadTest(gs *globalState, cmd *cobra.Command, args []string) (*loadedTest, error) { +func loadTest(gs *state.GlobalState, cmd *cobra.Command, args []string) (*loadedTest, error) { if len(args) < 1 { return nil, fmt.Errorf("k6 needs at least one argument to load the test") } sourceRootPath := args[0] - gs.logger.Debugf("Resolving and reading test '%s'...", sourceRootPath) + gs.Logger.Debugf("Resolving and reading test '%s'...", sourceRootPath) src, fileSystems, pwd, err := readSource(gs, sourceRootPath) if err != nil { return nil, err } resolvedPath := src.URL.String() - gs.logger.Debugf( + gs.Logger.Debugf( "'%s' resolved to '%s' and successfully loaded %d bytes!", sourceRootPath, resolvedPath, len(src.Data), ) - gs.logger.Debugf("Gathering k6 runtime options...") - runtimeOptions, err := getRuntimeOptions(cmd.Flags(), gs.envVars) + gs.Logger.Debugf("Gathering k6 runtime options...") + runtimeOptions, err := getRuntimeOptions(cmd.Flags(), gs.Env) if err != nil { return nil, err } registry := metrics.NewRegistry() state := &lib.TestPreInitState{ - Logger: gs.logger, + Logger: gs.Logger, RuntimeOptions: runtimeOptions, Registry: registry, BuiltinMetrics: metrics.RegisterBuiltinMetrics(registry), @@ -75,22 +76,22 @@ func loadTest(gs *globalState, cmd *cobra.Command, args []string) (*loadedTest, pwd: pwd, sourceRootPath: sourceRootPath, source: src, - fs: gs.fs, + fs: gs.FS, fileSystems: fileSystems, preInitState: state, } - gs.logger.Debugf("Initializing k6 runner for '%s' (%s)...", sourceRootPath, resolvedPath) + gs.Logger.Debugf("Initializing k6 runner for '%s' (%s)...", sourceRootPath, resolvedPath) if err := test.initializeFirstRunner(gs); err != nil { return nil, fmt.Errorf("could not initialize '%s': %w", sourceRootPath, err) } - gs.logger.Debug("Runner successfully initialized!") + gs.Logger.Debug("Runner successfully initialized!") return test, nil } -func (lt *loadedTest) initializeFirstRunner(gs *globalState) error { +func (lt *loadedTest) initializeFirstRunner(gs *state.GlobalState) error { testPath := lt.source.URL.String() - logger := gs.logger.WithField("test_path", testPath) + logger := gs.Logger.WithField("test_path", testPath) testType := lt.preInitState.RuntimeOptions.TestType.String if testType == "" { @@ -154,14 +155,14 @@ func (lt *loadedTest) initializeFirstRunner(gs *globalState) error { // readSource is a small wrapper around loader.ReadSource returning // result of the load and filesystems map -func readSource(globalState *globalState, filename string) (*loader.SourceData, map[string]afero.Fs, string, error) { - pwd, err := globalState.getwd() +func readSource(gs *state.GlobalState, filename string) (*loader.SourceData, map[string]afero.Fs, string, error) { + pwd, err := gs.Getwd() if err != nil { return nil, nil, "", err } - filesystems := loader.CreateFilesystems(globalState.fs) - src, err := loader.ReadSource(globalState.logger, filename, pwd, filesystems, globalState.console.Stdin) + filesystems := loader.CreateFilesystems(gs.FS) + src, err := loader.ReadSource(gs.Logger, filename, pwd, filesystems, gs.Console.Stdin) return src, filesystems, pwd, err } @@ -173,12 +174,12 @@ func detectTestType(data []byte) string { } func (lt *loadedTest) consolidateDeriveAndValidateConfig( - gs *globalState, cmd *cobra.Command, + gs *state.GlobalState, cmd *cobra.Command, cliConfGetter func(flags *pflag.FlagSet) (Config, error), // TODO: obviate ) (*loadedAndConfiguredTest, error) { var cliConfig Config if cliConfGetter != nil { - gs.logger.Debug("Parsing CLI flags...") + gs.Logger.Debug("Parsing CLI flags...") var err error cliConfig, err = cliConfGetter(cmd.Flags()) if err != nil { @@ -186,13 +187,13 @@ func (lt *loadedTest) consolidateDeriveAndValidateConfig( } } - gs.logger.Debug("Consolidating config layers...") + gs.Logger.Debug("Consolidating config layers...") consolidatedConfig, err := getConsolidatedConfig(gs, cliConfig, lt.initRunner.GetOptions()) if err != nil { return nil, err } - gs.logger.Debug("Parsing thresholds and validating config...") + gs.Logger.Debug("Parsing thresholds and validating config...") // Parse the thresholds, only if the --no-threshold flag is not set. // If parsing the threshold expressions failed, consider it as an // invalid configuration error. @@ -210,7 +211,7 @@ func (lt *loadedTest) consolidateDeriveAndValidateConfig( } } - derivedConfig, err := deriveAndValidateConfig(consolidatedConfig, lt.initRunner.IsExecutable, gs.logger) + derivedConfig, err := deriveAndValidateConfig(consolidatedConfig, lt.initRunner.IsExecutable, gs.Logger) if err != nil { return nil, err } @@ -231,7 +232,7 @@ type loadedAndConfiguredTest struct { } func loadAndConfigureTest( - gs *globalState, cmd *cobra.Command, args []string, + gs *state.GlobalState, cmd *cobra.Command, args []string, cliConfigGetter func(flags *pflag.FlagSet) (Config, error), ) (*loadedAndConfiguredTest, error) { test, err := loadTest(gs, cmd, args) diff --git a/cmd/ui.go b/cmd/ui.go index 8262b2889da0..535d3adcc20a 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -1,15 +1,18 @@ package cmd -import "go.k6.io/k6/ui/console/pb" +import ( + "go.k6.io/k6/cmd/state" + "go.k6.io/k6/ui/console/pb" +) -func maybePrintBanner(gs *globalState) { - if !gs.flags.quiet { - gs.console.Printf("\n%s\n\n", gs.console.Banner()) +func maybePrintBanner(gs *state.GlobalState) { + if !gs.Flags.Quiet { + gs.Console.Printf("\n%s\n\n", gs.Console.Banner()) } } -func maybePrintBar(gs *globalState, bar *pb.ProgressBar) { - if !gs.flags.quiet { - gs.console.PrintBar(bar) +func maybePrintBar(gs *state.GlobalState, bar *pb.ProgressBar) { + if !gs.Flags.Quiet { + gs.Console.PrintBar(bar) } } diff --git a/cmd/version.go b/cmd/version.go index 9b6ff28ef0a0..64a7200770be 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -5,26 +5,26 @@ import ( "strings" "github.com/spf13/cobra" - + "go.k6.io/k6/cmd/state" "go.k6.io/k6/ext" "go.k6.io/k6/lib/consts" ) -func getCmdVersion(gs *globalState) *cobra.Command { +func getCmdVersion(gs *state.GlobalState) *cobra.Command { // versionCmd represents the version command. return &cobra.Command{ Use: "version", Short: "Show application version", Long: `Show the application version and exit.`, Run: func(_ *cobra.Command, _ []string) { - gs.console.Printf("k6 v%s\n", consts.FullVersion()) + gs.Console.Printf("k6 v%s\n", consts.FullVersion()) if exts := ext.GetAll(); len(exts) > 0 { extsDesc := make([]string, 0, len(exts)) for _, e := range exts { extsDesc = append(extsDesc, fmt.Sprintf(" %s", e.String())) } - gs.console.Printf("Extensions:\n%s\n", strings.Join(extsDesc, "\n")) + gs.Console.Printf("Extensions:\n%s\n", strings.Join(extsDesc, "\n")) } }, } diff --git a/main.go b/main.go index fb58c5f23dd1..1eb81c649883 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,12 @@ package main import ( + "context" + "go.k6.io/k6/cmd" + "go.k6.io/k6/cmd/state" ) func main() { - cmd.Execute() + cmd.Execute(state.NewGlobalState(context.Background())) }