diff --git a/.gitignore b/.gitignore index 433acb5..52e4216 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,7 @@ unit-tests.xml .env # binary -release/ \ No newline at end of file +release/ + +# MacOS +.DS_STORE diff --git a/DOCS.md b/DOCS.md index 8f9adb5..3fdea71 100644 --- a/DOCS.md +++ b/DOCS.md @@ -67,10 +67,11 @@ export const options = { The following parameters are used to configure the image: -| Name | Description | Required | Default | -| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------- | -| `script_path` | path to the k6 script file. must be a JavaScript file satisfying the pattern `^([.]{0,2}/)?[a-zA-Z0-9-_/]\*[a-zA-Z0-9]\.js$`. | `true` | `N/A` | -| `output_path` | path to the output file that will be created. directories will be created as necessary. if empty, no output file will be generated. must be a JSON file satisfying the pattern `^([.]{0,2}/)?[a-zA-Z0-9-_/]\*[a-zA-Z0-9]\.json$`. | `false` | `N/A` | -| `fail_on_threshold_breach` | if `false`, the pipeline step will not fail even if thresholds are breached. | `false` | `true` | -| `projektor_compat_mode` | if `true`, output will be generated with the `--summary-output` flag instead of the `--out` flag. this is necessary for results uploaded to a [Projektor](https://projektor.dev/) server. | `false` | `false` | -| `log_progress` | if `true`, k6 progress bar output will print to the Vela pipeline. Not recommended for numerous or long-running tests, as logging becomes excessive. | `false` | `false` | +| Name | Description | Required | Default | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------- | +| `script_path` | path to the k6 script file. must be a JavaScript file satisfying the pattern `^(\./\|(\.\./)+)?[a-zA-Z0-9-_/]*[a-zA-Z0-9]\.js$`. | `true` | `N/A` | +| `output_path` | path to the output file that will be created. directories will be created as necessary. if empty, no output file will be generated. must be a JSON file satisfying the pattern `^(\./\|(\.\./)+)?[a-zA-Z0-9-_/]*[a-zA-Z0-9]\.json$`. | `false` | `N/A` | +| `setup_script_path` | path to an optional setup script file to be run before tests. must be a shell script (sh or bash) with execute permissions matching the pattern `^(\./\|(\.\./)+)?[a-zA-Z0-9-_/]*[a-zA-Z0-9]\.sh$`. | `false` | `N/A` | +| `fail_on_threshold_breach` | if `false`, the pipeline step will not fail even if thresholds are breached. | `false` | `true` | +| `projektor_compat_mode` | if `true`, output will be generated with the `--summary-output` flag instead of the `--out` flag. this is necessary for results uploaded to a [Projektor](https://projektor.dev/) server. | `false` | `false` | +| `log_progress` | if `true`, k6 progress bar output will print to the Vela pipeline. Not recommended for numerous or long-running tests, as logging becomes excessive. | `false` | `false` | diff --git a/Dockerfile b/Dockerfile index 330811f..7e3415d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM docker.io/grafana/k6:0.46.0@sha256:2f40a302ec1e1e3cc96b9a3871bf5d7d4697e9ec FROM alpine:3.18@sha256:7144f7bab3d4c2648d7e59409f15ec52a18006a128c733fcff20d3a4a54ba44a as certs -RUN apk add --update --no-cache ca-certificates +RUN apk add --update --no-cache ca-certificates bash COPY --from=k6-image /usr/bin/k6 /usr/bin/k6 diff --git a/Makefile b/Makefile index 6292a64..8526567 100644 --- a/Makefile +++ b/Makefile @@ -44,9 +44,8 @@ clean: clean-all go-tidy ## Clean up the application and test output .PHONY: build build: build-all ## Compile the application -.PHONY: build-docker -build-docker: ## Compile the application for testing locally with Docker - GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o $(BIN_NAME) +.PHONY: docker +docker: build-linux docker-build ## Build a Docker image for local testing .PHONY: test test: test-all ## Run all unit tests @@ -116,3 +115,23 @@ build-static-ci: @echo @echo "### Building CI static release/vela-k6 binary" go build -a -ldflags '-s -w -extldflags "-static" ${LD_FLAGS}' -o $(BIN_LOCATION) $(BIN_NAME) + +# The `build-linux` target is intended to compile +# the Go source code into a linux-compatible binary. +# +# Usage: `make build-linux` +.PHONY: build-linux +build-linux: + @echo + @echo "### Building release/vela-k6 binary for linux" + GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -a -ldflags '${LD_FLAGS}' -o $(BIN_LOCATION) $(BIN_NAME) + +# The `docker-build` target is intended to build +# the Docker image for the plugin. +# +# Usage: `make docker-build` +.PHONY: docker-build +docker-build: build-linux + @echo + @echo "### Building vela-k6:local image" + @docker build --no-cache -t vela-k6:local . diff --git a/main.go b/main.go index c8123e9..d9c9b79 100644 --- a/main.go +++ b/main.go @@ -29,6 +29,11 @@ func main() { log.Fatalf("FATAL: %s\n", err) } + err = plugin.RunSetupScript(cfg) + if err != nil { + log.Fatalf("FATAL: %s\n", err) + } + err = plugin.RunPerfTests(cfg) if err != nil { log.Fatalf("FATAL: %s\n", err) diff --git a/plugin/mock.go b/plugin/mock.go index e3ca6f5..05d411b 100644 --- a/plugin/mock.go +++ b/plugin/mock.go @@ -1,7 +1,6 @@ package plugin import ( - "fmt" "io" "os/exec" "strings" @@ -21,11 +20,7 @@ func (m *MockCommand) Wait() error { } func (m *MockCommand) String() (str string) { - for _, arg := range m.args { - str = fmt.Sprintf("%s %s", str, arg) - } - - return + return "" } func (m *MockCommand) StdoutPipe() (io.ReadCloser, error) { diff --git a/plugin/plugin.go b/plugin/plugin.go index e16597c..ce601a5 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -42,7 +42,9 @@ func checkOSStat(path string) error { } var ( - validFilePattern = regexp.MustCompile(`^(\./|(\.\./)+)?[a-zA-Z0-9-_/]*[a-zA-Z0-9]\.(json|js)$`) + validJSFilePattern = regexp.MustCompile(`^(\./|(\.\./)+)?[a-zA-Z0-9-_/]*[a-zA-Z0-9]\.js$`) + validJSONFilePattern = regexp.MustCompile(`^(\./|(\.\./)+)?[a-zA-Z0-9-_/]*[a-zA-Z0-9]\.json$`) + validShellFilePattern = regexp.MustCompile(`^(\./|(\.\./)+)?[a-zA-Z0-9-_/]*[a-zA-Z0-9]\.sh$`) // buildCommand can be swapped out for a mock function for unit testing. buildCommand = buildExecCommand // verifyFileExists can be swapped out for a mock function for unit testing. @@ -55,23 +57,36 @@ var ( // output path is invalid, OutputPath is set to "". func ConfigFromEnv() (*Config, error) { cfg := &Config{} - cfg.ScriptPath = sanitizeFilePath(os.Getenv("PARAMETER_SCRIPT_PATH")) - cfg.OutputPath = sanitizeFilePath(os.Getenv("PARAMETER_OUTPUT_PATH")) + cfg.ScriptPath = sanitizeScriptPath(os.Getenv("PARAMETER_SCRIPT_PATH")) + cfg.OutputPath = sanitizeOutputPath(os.Getenv("PARAMETER_OUTPUT_PATH")) + cfg.SetupScriptPath = sanitizeSetupPath(os.Getenv("PARAMETER_SETUP_SCRIPT_PATH")) cfg.FailOnThresholdBreach = !strings.EqualFold(os.Getenv("PARAMETER_FAIL_ON_THRESHOLD_BREACH"), "false") cfg.ProjektorCompatMode = strings.EqualFold(os.Getenv("PARAMETER_PROJEKTOR_COMPAT_MODE"), "true") cfg.LogProgress = strings.EqualFold(os.Getenv("PARAMETER_LOG_PROGRESS"), "true") if cfg.ScriptPath == "" || !strings.HasSuffix(cfg.ScriptPath, ".js") { - return nil, fmt.Errorf("invalid script file. provide the filepath to a JavaScript file in plugin parameter 'script_path' (e.g. 'script_path: \"/k6-test/script.js\"'). the filepath must follow the regular expression `^[a-zA-Z0-9-_/]*[a-zA-Z0-9]+\\.(json|js)$`") + return nil, fmt.Errorf("invalid script file. provide the filepath to a JavaScript file in plugin parameter 'script_path' (e.g. 'script_path: \"/k6-test/script.js\"'). the filepath must follow the regular expression `%s`", validJSFilePattern) } return cfg, nil } -// sanitizeFilePath returns the input string if it satisfies the pattern -// for a valid filepath, and an empty string otherwise. -func sanitizeFilePath(input string) string { - return validFilePattern.FindString(input) +// sanitizeScriptPath returns the input string if it satisfies the pattern +// for a valid JS filepath, and an empty string otherwise. +func sanitizeScriptPath(input string) string { + return validJSFilePattern.FindString(input) +} + +// sanitizeOutputPath returns the input string if it satisfies the pattern +// for a valid JSON filepath, and an empty string otherwise. +func sanitizeOutputPath(input string) string { + return validJSONFilePattern.FindString(input) +} + +// sanitizeSetupPath returns the input string if it satisfies the pattern +// for a valid .sh filepath, and an empty string otherwise. +func sanitizeSetupPath(input string) string { + return validShellFilePattern.FindString(input) } // buildK6Command returns a shellCommand that will execute K6 tests @@ -103,6 +118,52 @@ func buildK6Command(cfg *Config) (cmd shellCommand, err error) { return } +// RunSetupScript runs the setup script located at the cfg.SetupScriptPath +// if the path is not empty. +func RunSetupScript(cfg *Config) error { + if cfg.SetupScriptPath == "" { + log.Println("No setup script specified, skipping.") + return nil + } + + err := verifyFileExists(cfg.SetupScriptPath) + if err != nil { + return fmt.Errorf("read setup script file at %s: %w", cfg.SetupScriptPath, err) + } + + cmd := buildCommand(cfg.SetupScriptPath) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("get stdout pipe: %w", err) + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("get stderr pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("start command: %w", err) + } + + log.Println("Running setup script...") + + wg := sync.WaitGroup{} + wg.Add(2) + + go readLinesFromPipe(stdout, &wg) + go readLinesFromPipe(stderr, &wg) + wg.Wait() + + err = cmd.Wait() + if err != nil { + return fmt.Errorf("run setup script: %w", err) + } + + return nil +} + // RunPerfTests runs the K6 performance test script located at the // cfg.ScriptPath and saves the output to cfg.OutputPath if it is present // and a valid filepath. @@ -184,6 +245,7 @@ func readLinesFromPipe(pipe io.ReadCloser, wg *sync.WaitGroup) { type Config struct { ScriptPath string OutputPath string + SetupScriptPath string FailOnThresholdBreach bool ProjektorCompatMode bool LogProgress bool diff --git a/plugin/plugin_test.go b/plugin/plugin_test.go index 486fff6..b250159 100644 --- a/plugin/plugin_test.go +++ b/plugin/plugin_test.go @@ -15,42 +15,96 @@ import ( func setFilePathEnvs(t *testing.T) { t.Setenv("PARAMETER_SCRIPT_PATH", "./test/script.js") t.Setenv("PARAMETER_OUTPUT_PATH", "./output.json") + t.Setenv("PARAMETER_SETUP_SCRIPT_PATH", "./test/setup.sh") } func clearEnvironment(t *testing.T) { t.Setenv("PARAMETER_SCRIPT_PATH", "") t.Setenv("PARAMETER_OUTPUT_PATH", "") + t.Setenv("PARAMETER_SETUP_SCRIPT_PATH", "") t.Setenv("PARAMETER_PROJEKTOR_COMPAT_MODE", "") t.Setenv("PARAMETER_FAIL_ON_THRESHOLD_BREACH", "") t.Setenv("PARAMETER_LOG_PROGRESS", "") } -func TestSanitizeFilePath(t *testing.T) { +func TestSanitizeScriptPath(t *testing.T) { t.Run("Valid Filepaths", func(t *testing.T) { - assert.Equal(t, "file.js", sanitizeFilePath("file.js")) - assert.Equal(t, "./file.js", sanitizeFilePath("./file.js")) - assert.Equal(t, "../file.js", sanitizeFilePath("../file.js")) - assert.Equal(t, "../../../file.js", sanitizeFilePath("../../../file.js")) - assert.Equal(t, "file.json", sanitizeFilePath("file.json")) - assert.Equal(t, "file-dash_underscore.json", sanitizeFilePath("file-dash_underscore.json")) - assert.Equal(t, "path/to/file.js", sanitizeFilePath("path/to/file.js")) - assert.Equal(t, "/path/to/file.js", sanitizeFilePath("/path/to/file.js")) - assert.Equal(t, "path/to/file.json", sanitizeFilePath("path/to/file.json")) + assert.Equal(t, "file.js", sanitizeScriptPath("file.js")) + assert.Equal(t, "./file.js", sanitizeScriptPath("./file.js")) + assert.Equal(t, "../file.js", sanitizeScriptPath("../file.js")) + assert.Equal(t, "../../../file.js", sanitizeScriptPath("../../../file.js")) + assert.Equal(t, "file-dash_underscore.js", sanitizeScriptPath("file-dash_underscore.js")) + assert.Equal(t, "path/to/file.js", sanitizeScriptPath("path/to/file.js")) + assert.Equal(t, "/path/to/file.js", sanitizeScriptPath("/path/to/file.js")) }) t.Run("Invalid Filepaths", func(t *testing.T) { - assert.Equal(t, "", sanitizeFilePath(".../file.js")) - assert.Equal(t, "", sanitizeFilePath("./../file.js")) - assert.Equal(t, "", sanitizeFilePath("*/file.js")) - assert.Equal(t, "", sanitizeFilePath(".file.js")) - assert.Equal(t, "", sanitizeFilePath("/.json")) - assert.Equal(t, "", sanitizeFilePath("-.json")) - assert.Equal(t, "", sanitizeFilePath("_.json")) - assert.Equal(t, "", sanitizeFilePath("_invalid$name.json")) - assert.Equal(t, "", sanitizeFilePath("invalid$name.js")) - assert.Equal(t, "", sanitizeFilePath("invalidformat.png")) - assert.Equal(t, "", sanitizeFilePath("file.js; rm -rf /")) - assert.Equal(t, "", sanitizeFilePath("file.js && suspicious-call")) + assert.Equal(t, "", sanitizeScriptPath(".../file.js")) + assert.Equal(t, "", sanitizeScriptPath("./../file.js")) + assert.Equal(t, "", sanitizeScriptPath("*/file.js")) + assert.Equal(t, "", sanitizeScriptPath(".file.js")) + assert.Equal(t, "", sanitizeScriptPath("/.js")) + assert.Equal(t, "", sanitizeScriptPath("-.js")) + assert.Equal(t, "", sanitizeScriptPath("_.js")) + assert.Equal(t, "", sanitizeScriptPath("_invalid$name.js")) + assert.Equal(t, "", sanitizeScriptPath("invalid$name.js")) + assert.Equal(t, "", sanitizeScriptPath("invalidformat.png")) + assert.Equal(t, "", sanitizeScriptPath("file.js; rm -rf /")) + assert.Equal(t, "", sanitizeScriptPath("file.js && suspicious-call")) + }) +} + +func TestSanitizeOutputPath(t *testing.T) { + t.Run("Valid Filepaths", func(t *testing.T) { + assert.Equal(t, "file.json", sanitizeOutputPath("file.json")) + assert.Equal(t, "./file.json", sanitizeOutputPath("./file.json")) + assert.Equal(t, "../file.json", sanitizeOutputPath("../file.json")) + assert.Equal(t, "../../../file.json", sanitizeOutputPath("../../../file.json")) + assert.Equal(t, "file-dash_underscore.json", sanitizeOutputPath("file-dash_underscore.json")) + assert.Equal(t, "path/to/file.json", sanitizeOutputPath("path/to/file.json")) + assert.Equal(t, "/path/to/file.json", sanitizeOutputPath("/path/to/file.json")) + }) + + t.Run("Invalid Filepaths", func(t *testing.T) { + assert.Equal(t, "", sanitizeOutputPath(".../file.json")) + assert.Equal(t, "", sanitizeOutputPath("./../file.json")) + assert.Equal(t, "", sanitizeOutputPath("*/file.json")) + assert.Equal(t, "", sanitizeOutputPath(".file.json")) + assert.Equal(t, "", sanitizeOutputPath("/.json")) + assert.Equal(t, "", sanitizeOutputPath("-.json")) + assert.Equal(t, "", sanitizeOutputPath("_.json")) + assert.Equal(t, "", sanitizeOutputPath("_invalid$name.json")) + assert.Equal(t, "", sanitizeOutputPath("invalid$name.json")) + assert.Equal(t, "", sanitizeOutputPath("invalidformat.png")) + assert.Equal(t, "", sanitizeOutputPath("file.json; rm -rf /")) + assert.Equal(t, "", sanitizeOutputPath("file.json && suspicious-call")) + }) +} + +func TestSanitizeSetupPath(t *testing.T) { + t.Run("Valid Filepaths", func(t *testing.T) { + assert.Equal(t, "file.sh", sanitizeSetupPath("file.sh")) + assert.Equal(t, "./file.sh", sanitizeSetupPath("./file.sh")) + assert.Equal(t, "../file.sh", sanitizeSetupPath("../file.sh")) + assert.Equal(t, "../../../file.sh", sanitizeSetupPath("../../../file.sh")) + assert.Equal(t, "file-dash_underscore.sh", sanitizeSetupPath("file-dash_underscore.sh")) + assert.Equal(t, "path/to/file.sh", sanitizeSetupPath("path/to/file.sh")) + assert.Equal(t, "/path/to/file.sh", sanitizeSetupPath("/path/to/file.sh")) + }) + + t.Run("Invalid Filepaths", func(t *testing.T) { + assert.Equal(t, "", sanitizeSetupPath(".../file.sh")) + assert.Equal(t, "", sanitizeSetupPath("./../file.sh")) + assert.Equal(t, "", sanitizeSetupPath("*/file.sh")) + assert.Equal(t, "", sanitizeSetupPath(".file.sh")) + assert.Equal(t, "", sanitizeSetupPath("/.sh")) + assert.Equal(t, "", sanitizeSetupPath("-.sh")) + assert.Equal(t, "", sanitizeSetupPath("_.sh")) + assert.Equal(t, "", sanitizeSetupPath("_invalid$name.sh")) + assert.Equal(t, "", sanitizeSetupPath("invalid$name.sh")) + assert.Equal(t, "", sanitizeSetupPath("invalidformat.png")) + assert.Equal(t, "", sanitizeSetupPath("file.sh; rm -rf /")) + assert.Equal(t, "", sanitizeSetupPath("file.sh && suspicious-call")) }) } @@ -121,6 +175,59 @@ func TestBuildK6Command(t *testing.T) { }) } +func TestRunSetupScript(t *testing.T) { + clearEnvironment(t) + + buildCommand = MockCommandBuilderWithError(nil) + verifyFileExists = func(path string) error { + if path != "./test/setup.sh" { + return fmt.Errorf("File does not exist at path %s", path) + } + + return nil + } + + defer func() { + buildCommand = buildExecCommand + verifyFileExists = checkOSStat + }() + + t.Run("Successful setup script", func(t *testing.T) { + setFilePathEnvs(t) + cfg, err := ConfigFromEnv() + assert.NoError(t, err) + err = RunSetupScript(cfg) + assert.NoError(t, err) + }) + + t.Run("No setup script", func(t *testing.T) { + setFilePathEnvs(t) + t.Setenv("PARAMETER_SETUP_SCRIPT_PATH", "") + cfg, err := ConfigFromEnv() + assert.NoError(t, err) + err = RunSetupScript(cfg) + assert.NoError(t, err) + }) + + t.Run("Script file not present", func(t *testing.T) { + setFilePathEnvs(t) + t.Setenv("PARAMETER_SETUP_SCRIPT_PATH", "./test/doesnotexist.sh") + cfg, err := ConfigFromEnv() + assert.NoError(t, err) + err = RunSetupScript(cfg) + assert.ErrorContains(t, err, "read setup script file at") + }) + + t.Run("Setup script exec error", func(t *testing.T) { + buildCommand = MockCommandBuilderWithError(fmt.Errorf("some setup error")) + setFilePathEnvs(t) + cfg, err := ConfigFromEnv() + assert.NoError(t, err) + err = RunSetupScript(cfg) + assert.ErrorContains(t, err, "run setup script: some setup error") + }) +} + func TestRunPerfTests(t *testing.T) { clearEnvironment(t) @@ -145,6 +252,15 @@ func TestRunPerfTests(t *testing.T) { assert.NoError(t, err) }) + t.Run("Script file not present", func(t *testing.T) { + setFilePathEnvs(t) + t.Setenv("PARAMETER_SCRIPT_PATH", "./test/doesnotexist.js") + cfg, err := ConfigFromEnv() + assert.NoError(t, err) + err = RunPerfTests(cfg) + assert.ErrorContains(t, err, "read script file at") + }) + t.Run("Error if thresholds breached", func(t *testing.T) { buildCommand = MockCommandBuilderWithError(&MockThresholdError{}) setFilePathEnvs(t) @@ -163,6 +279,15 @@ func TestRunPerfTests(t *testing.T) { err = RunPerfTests(cfg) assert.NoError(t, err) }) + + t.Run("Other exec error", func(t *testing.T) { + buildCommand = MockCommandBuilderWithError(fmt.Errorf("some exec error")) + setFilePathEnvs(t) + cfg, err := ConfigFromEnv() + assert.NoError(t, err) + err = RunPerfTests(cfg) + assert.ErrorContains(t, err, "some exec error") + }) } func TestReadLinesFromPipe(t *testing.T) {