github.com/wmuizelaar/kpt@v0.0.0-20221018115725-bd564717b2ed/pkg/test/runner/runner.go (about) 1 // Copyright 2021 Google LLC 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package runner 16 17 import ( 18 "fmt" 19 "os" 20 "os/exec" 21 "path/filepath" 22 "regexp" 23 "strings" 24 "testing" 25 26 "github.com/GoogleContainerTools/kpt/internal/fnruntime" 27 "github.com/google/go-cmp/cmp" 28 ) 29 30 // Runner runs an e2e test 31 type Runner struct { 32 pkgName string 33 testCase TestCase 34 cmd string 35 t *testing.T 36 initialCommit string 37 kptBin string 38 } 39 40 func getKptBin() (string, error) { 41 p, err := exec.Command("which", "kpt").CombinedOutput() 42 if err != nil { 43 return "", fmt.Errorf("cannot find command 'kpt' in $PATH: %w", err) 44 } 45 return strings.TrimSpace(string(p)), nil 46 } 47 48 const ( 49 // If this env is set to "true", this e2e test framework will update the 50 // expected diff and results if they already exist. If will not change 51 // config.yaml. 52 updateExpectedEnv string = "KPT_E2E_UPDATE_EXPECTED" 53 54 expectedDir string = ".expected" 55 expectedResultsFile string = "results.yaml" 56 expectedDiffFile string = "diff.patch" 57 expectedConfigFile string = "config.yaml" 58 outDir string = "out" 59 setupScript string = "setup.sh" 60 teardownScript string = "teardown.sh" 61 execScript string = "exec.sh" 62 CommandFnEval string = "eval" 63 CommandFnRender string = "render" 64 65 allowWasmFlag string = "--allow-alpha-wasm" 66 ) 67 68 // NewRunner returns a new runner for pkg 69 func NewRunner(t *testing.T, testCase TestCase, c string) (*Runner, error) { 70 info, err := os.Stat(testCase.Path) 71 if err != nil { 72 return nil, fmt.Errorf("cannot open path %s: %w", testCase.Path, err) 73 } 74 if !info.IsDir() { 75 return nil, fmt.Errorf("path %s is not a directory", testCase.Path) 76 } 77 kptBin, err := getKptBin() 78 if err != nil { 79 t.Logf("failed to find kpt binary: %v", err) 80 } 81 if kptBin != "" { 82 t.Logf("Using kpt binary: %s", kptBin) 83 } 84 return &Runner{ 85 pkgName: filepath.Base(testCase.Path), 86 testCase: testCase, 87 cmd: c, 88 t: t, 89 kptBin: kptBin, 90 }, nil 91 } 92 93 // Run runs the test. 94 func (r *Runner) Run() error { 95 switch r.cmd { 96 case CommandFnEval: 97 return r.runFnEval() 98 case CommandFnRender: 99 return r.runFnRender() 100 default: 101 return fmt.Errorf("invalid command %s", r.cmd) 102 } 103 } 104 105 // runSetupScript runs the setup script if the test has it 106 func (r *Runner) runSetupScript(pkgPath string) error { 107 p, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, setupScript)) 108 if err != nil { 109 return err 110 } 111 if _, err := os.Stat(p); os.IsNotExist(err) { 112 return nil 113 } 114 cmd := getCommand(pkgPath, "bash", []string{p}) 115 r.t.Logf("running setup script: %q", cmd.String()) 116 if output, err := cmd.CombinedOutput(); err != nil { 117 return fmt.Errorf("failed to run setup script %q.\nOutput: %q\n: %w", p, string(output), err) 118 } 119 return nil 120 } 121 122 // runTearDownScript runs the teardown script if the test has it 123 func (r *Runner) runTearDownScript(pkgPath string) error { 124 p, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, teardownScript)) 125 if err != nil { 126 return err 127 } 128 if _, err := os.Stat(p); os.IsNotExist(err) { 129 return nil 130 } 131 cmd := getCommand(pkgPath, "bash", []string{p}) 132 r.t.Logf("running teardown script: %q", cmd.String()) 133 if output, err := cmd.CombinedOutput(); err != nil { 134 return fmt.Errorf("failed to run teardown script %q.\nOutput: %q\n: %w", p, string(output), err) 135 } 136 return nil 137 } 138 139 func (r *Runner) runFnEval() error { 140 r.t.Logf("Running test against package %s\n", r.pkgName) 141 tmpDir, err := os.MkdirTemp("", "kpt-fn-e2e-*") 142 if err != nil { 143 return fmt.Errorf("failed to create temporary dir: %w", err) 144 } 145 pkgPath := filepath.Join(tmpDir, r.pkgName) 146 147 if r.testCase.Config.Debug { 148 fmt.Printf("Running test against package %s in dir %s \n", r.pkgName, pkgPath) 149 } 150 if !r.testCase.Config.Debug { 151 // if debug is true, keep the test directory around for debugging 152 defer os.RemoveAll(tmpDir) 153 } 154 var resultsDir, destDir string 155 156 if r.IsFnResultExpected() { 157 resultsDir = filepath.Join(tmpDir, "results") 158 } 159 160 if r.IsOutOfPlace() { 161 destDir = filepath.Join(pkgPath, outDir) 162 } 163 164 // copy package to temp directory 165 err = copyDir(r.testCase.Path, pkgPath) 166 if err != nil { 167 return fmt.Errorf("failed to copy package: %w", err) 168 } 169 170 // init and commit package files 171 err = r.preparePackage(pkgPath) 172 if err != nil { 173 return fmt.Errorf("failed to prepare package: %w", err) 174 } 175 176 // run function 177 for i := 0; i < r.testCase.Config.RunCount(); i++ { 178 err = r.runSetupScript(pkgPath) 179 if err != nil { 180 return err 181 } 182 183 var cmd *exec.Cmd 184 execScriptPath, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, execScript)) 185 if err != nil { 186 return err 187 } 188 189 if _, err := os.Stat(execScriptPath); err == nil { 190 cmd = getCommand(pkgPath, "bash", []string{execScriptPath}) 191 } else { 192 kptArgs := []string{"fn", "eval", pkgPath} 193 194 if resultsDir != "" { 195 kptArgs = append(kptArgs, "--results-dir", resultsDir) 196 } 197 if destDir != "" { 198 kptArgs = append(kptArgs, "-o", destDir) 199 } 200 if r.testCase.Config.AllowWasm { 201 kptArgs = append(kptArgs, allowWasmFlag) 202 } 203 if r.testCase.Config.ImagePullPolicy != "" { 204 kptArgs = append(kptArgs, "--image-pull-policy", r.testCase.Config.ImagePullPolicy) 205 } 206 if r.testCase.Config.EvalConfig.Network { 207 kptArgs = append(kptArgs, "--network") 208 } 209 if r.testCase.Config.EvalConfig.Image != "" { 210 kptArgs = append(kptArgs, "--image", r.testCase.Config.EvalConfig.Image) 211 } else if !r.testCase.Config.EvalConfig.execUniquePath.Empty() { 212 kptArgs = append(kptArgs, "--exec", string(r.testCase.Config.EvalConfig.execUniquePath)) 213 } 214 if !r.testCase.Config.EvalConfig.fnConfigUniquePath.Empty() { 215 kptArgs = append(kptArgs, "--fn-config", string(r.testCase.Config.EvalConfig.fnConfigUniquePath)) 216 } 217 if r.testCase.Config.EvalConfig.IncludeMetaResources { 218 kptArgs = append(kptArgs, "--include-meta-resources") 219 } 220 // args must be appended last 221 if len(r.testCase.Config.EvalConfig.Args) > 0 { 222 kptArgs = append(kptArgs, "--") 223 for k, v := range r.testCase.Config.EvalConfig.Args { 224 kptArgs = append(kptArgs, fmt.Sprintf("%s=%s", k, v)) 225 } 226 } 227 cmd = getCommand("", r.kptBin, kptArgs) 228 } 229 r.t.Logf("running command: %v=%v %v", fnruntime.ContainerRuntimeEnv, os.Getenv(fnruntime.ContainerRuntimeEnv), cmd.String()) 230 stdout, stderr, fnErr := runCommand(cmd) 231 if fnErr != nil { 232 r.t.Logf("kpt error, stdout: %s; stderr: %s", stdout, stderr) 233 } 234 // Update the diff file or results file if updateExpectedEnv is set. 235 if strings.ToLower(os.Getenv(updateExpectedEnv)) == "true" { 236 return r.updateExpected(pkgPath, resultsDir, filepath.Join(r.testCase.Path, expectedDir)) 237 } 238 239 // compare results 240 err = r.compareResult(i, fnErr, stdout, sanitizeTimestamps(stderr), pkgPath, resultsDir) 241 if err != nil { 242 return err 243 } 244 // we passed result check, now we should break if the command error 245 // is expected 246 if fnErr != nil { 247 break 248 } 249 250 err = r.runTearDownScript(pkgPath) 251 if err != nil { 252 return err 253 } 254 } 255 256 return nil 257 } 258 259 func sanitizeTimestamps(stderr string) string { 260 // Output will have non-deterministic output timestamps. We will replace these to static message for 261 // stable comparison in tests. 262 var sanitized []string 263 for _, line := range strings.Split(stderr, "\n") { 264 // [PASS] \"gcr.io/kpt-fn/set-namespace:v0.1.3\" in 2.0s 265 if strings.HasPrefix(line, "[PASS]") || strings.HasPrefix(line, "[FAIL]") { 266 tokens := strings.Fields(line) 267 if len(tokens) == 4 && tokens[2] == "in" { 268 tokens[3] = "0s" 269 line = strings.Join(tokens, " ") 270 } 271 } 272 sanitized = append(sanitized, line) 273 } 274 return strings.Join(sanitized, "\n") 275 } 276 277 // IsFnResultExpected determines if function results are expected for this testcase. 278 func (r *Runner) IsFnResultExpected() bool { 279 _, err := os.ReadFile(filepath.Join(r.testCase.Path, expectedDir, expectedResultsFile)) 280 return err == nil 281 } 282 283 // IsOutOfPlace determines if command output is saved in a different directory (out-of-place). 284 func (r *Runner) IsOutOfPlace() bool { 285 _, err := os.ReadDir(filepath.Join(r.testCase.Path, outDir)) 286 return err == nil 287 } 288 289 func (r *Runner) runFnRender() error { 290 r.t.Logf("Running test against package %s\n", r.pkgName) 291 tmpDir, err := os.MkdirTemp("", "kpt-pipeline-e2e-*") 292 if err != nil { 293 return fmt.Errorf("failed to create temporary dir: %w", err) 294 } 295 if r.testCase.Config.Debug { 296 fmt.Printf("Running test against package %s in dir %s \n", r.pkgName, tmpDir) 297 } 298 if !r.testCase.Config.Debug { 299 // if debug is true, keep the test directory around for debugging 300 defer os.RemoveAll(tmpDir) 301 } 302 pkgPath := filepath.Join(tmpDir, r.pkgName) 303 // create dir to store untouched pkg to compare against 304 origPkgPath := filepath.Join(tmpDir, "original") 305 err = os.Mkdir(origPkgPath, 0755) 306 if err != nil { 307 return fmt.Errorf("failed to create original dir %s: %w", origPkgPath, err) 308 } 309 310 var resultsDir, destDir string 311 312 if r.IsFnResultExpected() { 313 resultsDir = filepath.Join(tmpDir, "results") 314 } 315 316 if r.IsOutOfPlace() { 317 destDir = filepath.Join(pkgPath, outDir) 318 } 319 320 // copy package to temp directory 321 err = copyDir(r.testCase.Path, pkgPath) 322 if err != nil { 323 return fmt.Errorf("failed to copy package: %w", err) 324 } 325 err = copyDir(r.testCase.Path, origPkgPath) 326 if err != nil { 327 return fmt.Errorf("failed to copy package: %w", err) 328 } 329 330 // init and commit package files 331 err = r.preparePackage(pkgPath) 332 if err != nil { 333 return fmt.Errorf("failed to prepare package: %w", err) 334 } 335 336 // run function 337 for i := 0; i < r.testCase.Config.RunCount(); i++ { 338 err = r.runSetupScript(pkgPath) 339 if err != nil { 340 return err 341 } 342 343 var cmd *exec.Cmd 344 345 execScriptPath, err := filepath.Abs(filepath.Join(r.testCase.Path, expectedDir, execScript)) 346 if err != nil { 347 return err 348 } 349 350 if _, err := os.Stat(execScriptPath); err == nil { 351 cmd = getCommand(pkgPath, "bash", []string{execScriptPath}) 352 } else { 353 kptArgs := []string{"fn", "render", pkgPath} 354 355 if resultsDir != "" { 356 kptArgs = append(kptArgs, "--results-dir", resultsDir) 357 } 358 359 if destDir != "" { 360 kptArgs = append(kptArgs, "-o", destDir) 361 } 362 363 if r.testCase.Config.ImagePullPolicy != "" { 364 kptArgs = append(kptArgs, "--image-pull-policy", r.testCase.Config.ImagePullPolicy) 365 } 366 367 if r.testCase.Config.AllowExec { 368 kptArgs = append(kptArgs, "--allow-exec") 369 } 370 371 if r.testCase.Config.AllowWasm { 372 kptArgs = append(kptArgs, allowWasmFlag) 373 } 374 375 if r.testCase.Config.DisableOutputTruncate { 376 kptArgs = append(kptArgs, "--truncate-output=false") 377 } 378 cmd = getCommand("", r.kptBin, kptArgs) 379 } 380 r.t.Logf("running command: %v=%v %v", fnruntime.ContainerRuntimeEnv, os.Getenv(fnruntime.ContainerRuntimeEnv), cmd.String()) 381 stdout, stderr, fnErr := runCommand(cmd) 382 // Update the diff file or results file if updateExpectedEnv is set. 383 if strings.ToLower(os.Getenv(updateExpectedEnv)) == "true" { 384 return r.updateExpected(pkgPath, resultsDir, filepath.Join(r.testCase.Path, expectedDir)) 385 } 386 387 if fnErr != nil { 388 r.t.Logf("kpt error, stdout: %s; stderr: %s", stdout, stderr) 389 } 390 // compare results 391 err = r.compareResult(i, fnErr, stdout, sanitizeTimestamps(stderr), pkgPath, resultsDir) 392 if err != nil { 393 return err 394 } 395 // we passed result check, now we should run teardown script and break 396 // if the command error is expected 397 err = r.runTearDownScript(pkgPath) 398 if err != nil { 399 return err 400 } 401 if fnErr != nil { 402 break 403 } 404 } 405 return nil 406 } 407 408 func (r *Runner) preparePackage(pkgPath string) error { 409 err := gitInit(pkgPath) 410 if err != nil { 411 return err 412 } 413 414 err = gitAddAll(pkgPath) 415 if err != nil { 416 return err 417 } 418 419 err = gitCommit(pkgPath, "first") 420 if err != nil { 421 return err 422 } 423 424 r.initialCommit, err = getCommitHash(pkgPath) 425 return err 426 } 427 428 func (r *Runner) compareResult(cnt int, exitErr error, stdout string, stderr string, tmpPkgPath, resultsPath string) error { 429 expected, err := newExpected(tmpPkgPath) 430 if err != nil { 431 return err 432 } 433 // get exit code 434 exitCode := 0 435 if e, ok := exitErr.(*exec.ExitError); ok { 436 exitCode = e.ExitCode() 437 } else if exitErr != nil { 438 return fmt.Errorf("cannot get exit code, received error '%w'", exitErr) 439 } 440 441 if exitCode != r.testCase.Config.ExitCode { 442 return fmt.Errorf("actual exit code %d doesn't match expected %d", exitCode, r.testCase.Config.ExitCode) 443 } 444 445 // we only check output and results for the first iteration of running because 446 // idempotency is only applied to changes in file system. 447 if cnt == 0 { 448 err = r.compareOutput(stdout, stderr) 449 if err != nil { 450 return err 451 } 452 453 // compare results 454 actual, err := readActualResults(resultsPath) 455 if err != nil { 456 return fmt.Errorf("failed to read actual results: %w", err) 457 } 458 diffOfResult, err := diffStrings(actual, expected.Results) 459 if err != nil { 460 return fmt.Errorf("error when run diff of results: %w: %s", err, diffOfResult) 461 } 462 if actual != expected.Results { 463 return fmt.Errorf("actual results doesn't match expected\nActual\n===\n%s\nDiff of Results\n===\n%s", 464 actual, diffOfResult) 465 } 466 } 467 468 // compare diff 469 actual, err := readActualDiff(tmpPkgPath, r.initialCommit) 470 if err != nil { 471 return fmt.Errorf("failed to read actual diff: %w", err) 472 } 473 if actual != expected.Diff { 474 diffOfDiff, err := diffStrings(actual, expected.Diff) 475 if err != nil { 476 return fmt.Errorf("error when run diff of diff: %w: %s", err, diffOfDiff) 477 } 478 return fmt.Errorf("actual diff doesn't match expected\nActual\n===\n%s\nDiff of Diff\n===\n%s", 479 actual, diffOfDiff) 480 } 481 return nil 482 } 483 484 // check stdout and stderr against expected 485 func (r *Runner) compareOutput(stdout string, stderr string) error { 486 expectedStderr := r.testCase.Config.StdErr 487 if !strings.Contains(stderr, expectedStderr) { 488 r.t.Logf("stderr diff is %s", cmp.Diff(expectedStderr, stderr)) 489 return fmt.Errorf("wanted stderr %q, got %q", expectedStderr, stderr) 490 } 491 stdErrRegEx := r.testCase.Config.StdErrRegEx 492 if stdErrRegEx != "" { 493 r, err := regexp.Compile(stdErrRegEx) 494 if err != nil { 495 return fmt.Errorf("unable to compile the regular expression %q: %w", stdErrRegEx, err) 496 } 497 if !r.MatchString(stderr) { 498 return fmt.Errorf("unable to match regular expression %q, got %v", stdErrRegEx, stderr) 499 } 500 } 501 expectedStdout := r.testCase.Config.StdOut 502 if !strings.Contains(stdout, expectedStdout) { 503 r.t.Logf("stdout diff is %s", cmp.Diff(expectedStdout, stdout)) 504 return fmt.Errorf("wanted stdout %q, got %q", expectedStdout, stdout) 505 } 506 return nil 507 } 508 509 func (r *Runner) Skip() bool { 510 return r.testCase.Config.Skip 511 } 512 513 func readActualResults(resultsPath string) (string, error) { 514 // no results 515 if resultsPath == "" { 516 return "", nil 517 } 518 l, err := os.ReadDir(resultsPath) 519 if err != nil { 520 return "", fmt.Errorf("failed to get files in results dir: %w", err) 521 } 522 if len(l) > 1 { 523 return "", fmt.Errorf("unexpected results files number %d, should be 0 or 1", len(l)) 524 } 525 if len(l) == 0 { 526 // no result file 527 return "", nil 528 } 529 resultsFile := l[0].Name() 530 actualResults, err := os.ReadFile(filepath.Join(resultsPath, resultsFile)) 531 if err != nil { 532 return "", fmt.Errorf("failed to read actual results: %w", err) 533 } 534 return strings.TrimSpace(string(actualResults)), nil 535 } 536 537 func readActualDiff(path, origHash string) (string, error) { 538 err := gitAddAll(path) 539 if err != nil { 540 return "", err 541 } 542 err = gitCommit(path, "second") 543 if err != nil { 544 return "", err 545 } 546 // diff with first commit 547 actualDiff, err := gitDiff(path, origHash, "HEAD") 548 if err != nil { 549 return "", err 550 } 551 return strings.TrimSpace(actualDiff), nil 552 } 553 554 // expected contains the expected result for the function running 555 type expected struct { 556 Results string 557 Diff string 558 } 559 560 func newExpected(path string) (expected, error) { 561 e := expected{} 562 // get expected results 563 expectedResults, err := os.ReadFile(filepath.Join(path, expectedDir, expectedResultsFile)) 564 switch { 565 case os.IsNotExist(err): 566 e.Results = "" 567 case err != nil: 568 return e, fmt.Errorf("failed to read expected results: %w", err) 569 default: 570 e.Results = strings.TrimSpace(string(expectedResults)) 571 } 572 573 // get expected diff 574 expectedDiff, err := os.ReadFile(filepath.Join(path, expectedDir, expectedDiffFile)) 575 switch { 576 case os.IsNotExist(err): 577 e.Diff = "" 578 case err != nil: 579 return e, fmt.Errorf("failed to read expected diff: %w", err) 580 default: 581 e.Diff = strings.TrimSpace(string(expectedDiff)) 582 } 583 584 return e, nil 585 } 586 587 func (r *Runner) updateExpected(tmpPkgPath, resultsPath, sourceOfTruthPath string) error { 588 if resultsPath != "" { 589 // We update results directory only when a result file already exists. 590 l, err := os.ReadDir(resultsPath) 591 if err != nil { 592 return err 593 } 594 if len(l) > 0 { 595 actualResults, err := readActualResults(resultsPath) 596 if err != nil { 597 return err 598 } 599 if actualResults != "" { 600 if err := os.WriteFile(filepath.Join(sourceOfTruthPath, expectedResultsFile), []byte(actualResults+"\n"), 0666); err != nil { 601 return err 602 } 603 } 604 } 605 } 606 actualDiff, err := readActualDiff(tmpPkgPath, r.initialCommit) 607 if err != nil { 608 return err 609 } 610 if actualDiff != "" { 611 if err := os.WriteFile(filepath.Join(sourceOfTruthPath, expectedDiffFile), []byte(actualDiff+"\n"), 0666); err != nil { 612 return err 613 } 614 } 615 616 return nil 617 }