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 }