github.com/kubeshop/testkube@v1.17.23/contrib/executor/k6/pkg/runner/runner.go (about)

     1  package runner
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"os"
     7  	"path/filepath"
     8  	"regexp"
     9  	"strings"
    10  
    11  	"github.com/pkg/errors"
    12  
    13  	"github.com/kubeshop/testkube/pkg/api/v1/testkube"
    14  	"github.com/kubeshop/testkube/pkg/envs"
    15  	"github.com/kubeshop/testkube/pkg/executor"
    16  	"github.com/kubeshop/testkube/pkg/executor/agent"
    17  	"github.com/kubeshop/testkube/pkg/executor/env"
    18  	outputPkg "github.com/kubeshop/testkube/pkg/executor/output"
    19  	"github.com/kubeshop/testkube/pkg/executor/runner"
    20  	"github.com/kubeshop/testkube/pkg/executor/scraper"
    21  	"github.com/kubeshop/testkube/pkg/executor/scraper/factory"
    22  	"github.com/kubeshop/testkube/pkg/ui"
    23  )
    24  
    25  func NewRunner(ctx context.Context, params envs.Params) (*K6Runner, error) {
    26  	outputPkg.PrintLogf("%s Preparing test runner", ui.IconTruck)
    27  
    28  	var err error
    29  	r := &K6Runner{
    30  		Params: params,
    31  	}
    32  
    33  	r.Scraper, err = factory.TryGetScrapper(ctx, params)
    34  	if err != nil {
    35  		return nil, err
    36  	}
    37  
    38  	return r, nil
    39  }
    40  
    41  type K6Runner struct {
    42  	Params  envs.Params
    43  	Scraper scraper.Scraper
    44  }
    45  
    46  var _ runner.Runner = &K6Runner{}
    47  
    48  const K6Cloud = "cloud"
    49  const K6Run = "run"
    50  
    51  func (r *K6Runner) Run(ctx context.Context, execution testkube.Execution) (result testkube.ExecutionResult, err error) {
    52  	if r.Scraper != nil {
    53  		defer r.Scraper.Close()
    54  	}
    55  
    56  	outputPkg.PrintLogf("%s Preparing for test run", ui.IconTruck)
    57  
    58  	// check that the datadir exists
    59  	_, err = os.Stat(r.Params.DataDir)
    60  	if errors.Is(err, os.ErrNotExist) {
    61  		outputPkg.PrintLogf("%s Datadir %s does not exist", ui.IconCross, r.Params.DataDir)
    62  		return result, err
    63  	}
    64  
    65  	var k6Command string
    66  	k6TestType := strings.Split(execution.TestType, "/")
    67  	if len(k6TestType) != 2 {
    68  		outputPkg.PrintLogf("%s Invalid test type %s", ui.IconCross, execution.TestType)
    69  		return *result.Err(errors.Errorf("invalid test type %s", execution.TestType)), nil
    70  	}
    71  
    72  	k6Subtype := k6TestType[1]
    73  	if k6Subtype == K6Cloud {
    74  		k6Command = K6Cloud
    75  	} else {
    76  		k6Command = K6Run
    77  	}
    78  
    79  	var envVars []string
    80  	envManager := env.NewManagerWithVars(execution.Variables)
    81  	envManager.GetReferenceVars(envManager.Variables)
    82  	for _, variable := range envManager.Variables {
    83  		if variable.Name != "K6_CLOUD_TOKEN" {
    84  			// pass to k6 using -e option
    85  			envvar := fmt.Sprintf("%s=%s", variable.Name, variable.Value)
    86  			envVars = append(envVars, "-e", envvar)
    87  		}
    88  	}
    89  
    90  	// convert executor env variables to k6 env variables
    91  	// Deprecated: use Basic Variable instead
    92  	for key, value := range execution.Envs {
    93  		if key != "K6_CLOUD_TOKEN" {
    94  			// pass to k6 using -e option
    95  			envvar := fmt.Sprintf("%s=%s", key, value)
    96  			envVars = append(envVars, "-e", envvar)
    97  		}
    98  	}
    99  
   100  	var directory string
   101  	var testPath string
   102  	args := execution.Args
   103  	// in case of a test file execution we will pass the
   104  	// file path as final parameter to k6
   105  	if execution.Content.Type_ == string(testkube.TestContentTypeString) ||
   106  		execution.Content.Type_ == string(testkube.TestContentTypeFileURI) {
   107  		directory = r.Params.DataDir
   108  		testPath = "test-content"
   109  	}
   110  
   111  	// in case of Git directory we will run k6 here and
   112  	// use the last argument as test file
   113  	changedArgs := false
   114  	if execution.Content.Type_ == string(testkube.TestContentTypeGitFile) ||
   115  		execution.Content.Type_ == string(testkube.TestContentTypeGitDir) ||
   116  		execution.Content.Type_ == string(testkube.TestContentTypeGit) {
   117  		directory = filepath.Join(r.Params.DataDir, "repo")
   118  		path := ""
   119  		workingDir := ""
   120  		if execution.Content != nil && execution.Content.Repository != nil {
   121  			path = execution.Content.Repository.Path
   122  			workingDir = execution.Content.Repository.WorkingDir
   123  		}
   124  
   125  		fileInfo, err := os.Stat(filepath.Join(directory, path))
   126  		if err != nil {
   127  			outputPkg.PrintLogf("%s k6 test directory %v not found", ui.IconCross, err)
   128  			return *result.Err(errors.Errorf("k6 test directory %v not found", err)), nil
   129  		}
   130  
   131  		if fileInfo.IsDir() {
   132  			testPath = filepath.Join(path, args[len(args)-1])
   133  			args = append(args[:len(args)-1], args[len(args):]...)
   134  			changedArgs = true
   135  		} else {
   136  			testPath = path
   137  		}
   138  
   139  		// sanity checking for test script
   140  		scriptFile := filepath.Join(directory, workingDir, testPath)
   141  		fileInfo, err = os.Stat(scriptFile)
   142  		if errors.Is(err, os.ErrNotExist) || fileInfo.IsDir() {
   143  			outputPkg.PrintLogf("%s k6 test script %s not found", ui.IconCross, scriptFile)
   144  			return *result.Err(errors.Errorf("k6 test script %s not found", scriptFile)), nil
   145  		}
   146  	}
   147  
   148  	hasRunPath := false
   149  	for i := range args {
   150  		if args[i] == "<k6Command>" {
   151  			args[i] = k6Command
   152  		}
   153  
   154  		if args[i] == "<runPath>" {
   155  			args[i] = testPath
   156  			hasRunPath = true
   157  		}
   158  	}
   159  
   160  	if changedArgs && !hasRunPath {
   161  		args = append(args, testPath)
   162  	}
   163  
   164  	for i := range args {
   165  		if args[i] == "<envVars>" {
   166  			newArgs := make([]string, len(args)+len(envVars)-1)
   167  			copy(newArgs, args[:i])
   168  			copy(newArgs[i:], envVars)
   169  			copy(newArgs[i+len(envVars):], args[i+1:])
   170  			args = newArgs
   171  			break
   172  		}
   173  	}
   174  
   175  	for i := range args {
   176  		args[i] = os.ExpandEnv(args[i])
   177  	}
   178  
   179  	command, args := executor.MergeCommandAndArgs(execution.Command, args)
   180  	outputPkg.PrintEvent("Running", directory, command, envManager.ObfuscateStringSlice(args))
   181  	runPath := directory
   182  	if execution.Content.Repository != nil && execution.Content.Repository.WorkingDir != "" {
   183  		runPath = filepath.Join(directory, execution.Content.Repository.WorkingDir)
   184  	}
   185  
   186  	output, err := executor.Run(runPath, command, envManager, args...)
   187  	output = envManager.ObfuscateSecrets(output)
   188  
   189  	var rerr error
   190  	if execution.PostRunScript != "" && execution.ExecutePostRunScriptBeforeScraping {
   191  		outputPkg.PrintLog(fmt.Sprintf("%s Running post run script...", ui.IconCheckMark))
   192  
   193  		if runPath == "" {
   194  			runPath = r.Params.WorkingDir
   195  		}
   196  
   197  		if rerr = agent.RunScript(execution.PostRunScript, runPath); rerr != nil {
   198  			outputPkg.PrintLogf("%s Failed to execute post run script %s", ui.IconWarning, rerr)
   199  		}
   200  	}
   201  
   202  	// scrape artifacts first even if there are errors above
   203  	if r.Params.ScrapperEnabled && execution.ArtifactRequest != nil && len(execution.ArtifactRequest.Dirs) != 0 {
   204  		outputPkg.PrintLogf("Scraping directories: %v with masks: %v", execution.ArtifactRequest.Dirs, execution.ArtifactRequest.Masks)
   205  
   206  		if err := r.Scraper.Scrape(ctx, execution.ArtifactRequest.Dirs, execution.ArtifactRequest.Masks, execution); err != nil {
   207  			return *result.WithErrors(err), nil
   208  		}
   209  	}
   210  
   211  	if rerr != nil {
   212  		return *result.Err(rerr), nil
   213  	}
   214  
   215  	return finalExecutionResult(string(output), err), nil
   216  }
   217  
   218  // finalExecutionResult processes the output of the test run
   219  func finalExecutionResult(output string, err error) (result testkube.ExecutionResult) {
   220  	succeeded := isSuccessful(output)
   221  	switch {
   222  	case err == nil && succeeded:
   223  		outputPkg.PrintLogf("%s Test run successful", ui.IconCheckMark)
   224  		result.Status = testkube.ExecutionStatusPassed
   225  	case err == nil && !succeeded:
   226  		outputPkg.PrintLogf("%s Test run failed: some checks have failed", ui.IconCross)
   227  		result.Status = testkube.ExecutionStatusFailed
   228  		result.ErrorMessage = "some checks have failed"
   229  	case err != nil && strings.Contains(err.Error(), "exit status 99"):
   230  		// tests have run, but some checks + thresholds have failed
   231  		outputPkg.PrintLogf("%s Test run failed: some thresholds have failed: %s", ui.IconCross, err.Error())
   232  		result.Status = testkube.ExecutionStatusFailed
   233  		result.ErrorMessage = "some thresholds have failed"
   234  	default:
   235  		// k6 was unable to run at all
   236  		outputPkg.PrintLogf("%s Test run failed: %s", ui.IconCross, err.Error())
   237  		result.Status = testkube.ExecutionStatusFailed
   238  		result.ErrorMessage = err.Error()
   239  		return result
   240  	}
   241  
   242  	// always set these, no matter if error or success
   243  	result.Output = output
   244  	result.OutputType = "text/plain"
   245  
   246  	result.Steps = []testkube.ExecutionStepResult{}
   247  	for _, name := range parseScenarioNames(output) {
   248  		result.Steps = append(result.Steps, testkube.ExecutionStepResult{
   249  			// use the scenario name with description here
   250  			Name:     name,
   251  			Duration: parseScenarioDuration(output, splitScenarioName(name)),
   252  
   253  			// currently there is no way to extract individual scenario status
   254  			Status: string(testkube.PASSED_ExecutionStatus),
   255  		})
   256  	}
   257  
   258  	return result
   259  }
   260  
   261  // isSuccessful checks the output of the k6 test to make sure nothing fails
   262  func isSuccessful(summary string) bool {
   263  	return areChecksSuccessful(summary) && !containsErrors(summary)
   264  }
   265  
   266  // areChecksSuccessful verifies the summary at the end of the execution to see
   267  // if any of the checks failed
   268  func areChecksSuccessful(summary string) bool {
   269  	lines := splitSummaryBody(summary)
   270  	re, err := regexp.Compile(`checks\.+: `)
   271  	if err != nil {
   272  		outputPkg.PrintLogf("%s Regexp error: %s", ui.IconWarning, err.Error())
   273  		return true
   274  	}
   275  
   276  	for _, line := range lines {
   277  		if !re.MatchString(line) {
   278  			continue
   279  		}
   280  		return strings.Contains(line, "100.00%")
   281  	}
   282  
   283  	return true
   284  }
   285  
   286  // containsErrors checks for error level messages.
   287  // As discussed in this GitHub issue: https://github.com/grafana/k6/issues/1680,
   288  // k6 summary does not include tests failing because an error was encountered.
   289  // To make sure no errors happened, we check the output for error level messages
   290  func containsErrors(summary string) bool {
   291  	return strings.Contains(summary, "level=error")
   292  }
   293  
   294  func parseScenarioNames(summary string) []string {
   295  	lines := splitSummaryBody(summary)
   296  	var names []string
   297  
   298  	for _, line := range lines {
   299  		if strings.Contains(line, "* ") {
   300  			name := strings.TrimLeft(strings.TrimSpace(line), "* ")
   301  			names = append(names, name)
   302  		}
   303  	}
   304  
   305  	return names
   306  }
   307  
   308  func parseScenarioDuration(summary string, name string) string {
   309  	lines := splitSummaryBody(summary)
   310  
   311  	var duration string
   312  	for _, line := range lines {
   313  		if strings.Contains(line, name) && strings.Contains(line, "[ 100% ]") {
   314  			index := strings.Index(line, "]") + 1
   315  			line = strings.TrimSpace(line[index:])
   316  			line = strings.ReplaceAll(line, "  ", " ")
   317  
   318  			// take next line and trim leading spaces
   319  			metrics := strings.Split(line, " ")
   320  			duration = metrics[2]
   321  			break
   322  		}
   323  	}
   324  
   325  	return duration
   326  }
   327  
   328  func splitScenarioName(name string) string {
   329  	return strings.Split(name, ":")[0]
   330  }
   331  
   332  func splitSummaryBody(summary string) []string {
   333  	return strings.Split(summary, "\n")
   334  }
   335  
   336  // GetType returns runner type
   337  func (r *K6Runner) GetType() runner.Type {
   338  	return runner.TypeMain
   339  }