github.com/saucelabs/saucectl@v0.175.1/internal/saucecloud/imagerunner.go (about) 1 package saucecloud 2 3 import ( 4 "archive/zip" 5 "context" 6 "encoding/base64" 7 "errors" 8 "fmt" 9 "io" 10 "os" 11 "os/signal" 12 "path/filepath" 13 "reflect" 14 "strings" 15 "time" 16 17 "github.com/rs/zerolog/log" 18 "github.com/ryanuber/go-glob" 19 szip "github.com/saucelabs/saucectl/internal/archive/zip" 20 "github.com/saucelabs/saucectl/internal/config" 21 "github.com/saucelabs/saucectl/internal/fileio" 22 "github.com/saucelabs/saucectl/internal/imagerunner" 23 "github.com/saucelabs/saucectl/internal/msg" 24 "github.com/saucelabs/saucectl/internal/report" 25 "github.com/saucelabs/saucectl/internal/tunnel" 26 ) 27 28 type ImageRunner interface { 29 TriggerRun(context.Context, imagerunner.RunnerSpec) (imagerunner.Runner, error) 30 GetStatus(ctx context.Context, id string) (imagerunner.Runner, error) 31 StopRun(ctx context.Context, id string) error 32 DownloadArtifacts(ctx context.Context, id string) (io.ReadCloser, error) 33 GetLogs(ctx context.Context, id string) (string, error) 34 StreamLiveLogs(ctx context.Context, id string, wait bool) error 35 GetLiveLogs(ctx context.Context, id string) error 36 } 37 38 type SuiteTimeoutError struct { 39 Timeout time.Duration 40 } 41 42 func (s SuiteTimeoutError) Error() string { 43 return fmt.Sprintf("suite timed out after %s", s.Timeout) 44 } 45 46 var ErrSuiteCancelled = errors.New("suite cancelled") 47 48 type ImgRunner struct { 49 Project imagerunner.Project 50 RunnerService ImageRunner 51 TunnelService tunnel.Service 52 53 Reporters []report.Reporter 54 55 Async bool 56 AsyncEventManager imagerunner.AsyncEventManager 57 58 ctx context.Context 59 cancel context.CancelFunc 60 } 61 62 func NewImgRunner(project imagerunner.Project, runnerService ImageRunner, tunnelService tunnel.Service, 63 asyncEventManager imagerunner.AsyncEventManager, reporters []report.Reporter, async bool) *ImgRunner { 64 return &ImgRunner{ 65 Project: project, 66 RunnerService: runnerService, 67 TunnelService: tunnelService, 68 Reporters: reporters, 69 Async: async, 70 AsyncEventManager: asyncEventManager, 71 } 72 } 73 74 type execResult struct { 75 name string 76 runID string 77 status string 78 assetsStatus string 79 err error 80 duration time.Duration 81 startTime time.Time 82 endTime time.Time 83 attempts []report.Attempt 84 } 85 86 func (r *ImgRunner) RunProject() (int, error) { 87 if err := tunnel.Validate( 88 r.TunnelService, 89 r.Project.Sauce.Tunnel.Name, 90 r.Project.Sauce.Tunnel.Owner, 91 tunnel.NoneFilter, 92 false, 93 r.Project.Sauce.Tunnel.Timeout, 94 ); err != nil { 95 return 1, err 96 } 97 98 if r.Project.DryRun { 99 printDryRunSuiteNames(r.getSuiteNames()) 100 return 0, nil 101 } 102 103 ctx, cancel := context.WithCancel(context.Background()) 104 r.ctx = ctx 105 r.cancel = cancel 106 107 sigChan := r.registerInterruptOnSignal() 108 defer unregisterSignalCapture(sigChan) 109 110 suites, results := r.createWorkerPool(r.Project.Sauce.Concurrency, 0) 111 112 // Submit suites to work on. 113 go func() { 114 for _, s := range r.Project.Suites { 115 suites <- s 116 } 117 }() 118 119 if passed := r.collectResults(results, len(r.Project.Suites)); !passed { 120 return 1, nil 121 } 122 123 return 0, nil 124 } 125 126 func (r *ImgRunner) createWorkerPool(ccy int, maxRetries int) (chan imagerunner.Suite, chan execResult) { 127 suites := make(chan imagerunner.Suite, maxRetries+1) 128 results := make(chan execResult, ccy) 129 130 log.Info().Int("concurrency", ccy).Msg("Launching workers.") 131 for i := 0; i < ccy; i++ { 132 go r.runSuites(suites, results) 133 } 134 135 return suites, results 136 } 137 138 func (r *ImgRunner) runSuites(suites chan imagerunner.Suite, results chan<- execResult) { 139 for suite := range suites { 140 // Apply defaults. 141 defaults := r.Project.Defaults 142 if defaults.Name != "" { 143 suite.Name = defaults.Name + " " + suite.Name 144 } 145 146 suite.Image = orDefault(suite.Image, defaults.Image) 147 suite.ImagePullAuth = orDefault(suite.ImagePullAuth, defaults.ImagePullAuth) 148 suite.EntryPoint = orDefault(suite.EntryPoint, defaults.EntryPoint) 149 suite.Timeout = orDefault(suite.Timeout, defaults.Timeout) 150 suite.Files = append(suite.Files, defaults.Files...) 151 suite.Artifacts = append(suite.Artifacts, defaults.Artifacts...) 152 153 if suite.Env == nil { 154 suite.Env = make(map[string]string) 155 } 156 for k, v := range defaults.Env { 157 suite.Env[k] = v 158 } 159 160 startTime := time.Now() 161 162 if r.ctx.Err() != nil { 163 results <- execResult{ 164 name: suite.Name, 165 startTime: startTime, 166 endTime: time.Now(), 167 duration: time.Since(startTime), 168 status: imagerunner.StateCancelled, 169 err: ErrSuiteCancelled, 170 } 171 continue 172 } 173 174 run, err := r.runSuite(suite) 175 176 endTime := time.Now() 177 duration := time.Since(startTime) 178 179 results <- execResult{ 180 name: suite.Name, 181 runID: run.ID, 182 status: run.Status, 183 assetsStatus: run.Assets.Status, 184 err: err, 185 startTime: startTime, 186 endTime: endTime, 187 duration: duration, 188 attempts: []report.Attempt{{ 189 ID: run.ID, 190 Duration: duration, 191 StartTime: startTime, 192 EndTime: endTime, 193 Status: run.Status, 194 }}, 195 } 196 } 197 } 198 199 func (r *ImgRunner) buildService(serviceIn imagerunner.SuiteService, suiteName string) (imagerunner.Service, error) { 200 var auth *imagerunner.Auth 201 if serviceIn.ImagePullAuth.User != "" && serviceIn.ImagePullAuth.Token != "" { 202 auth = &imagerunner.Auth{ 203 User: serviceIn.ImagePullAuth.User, 204 Token: serviceIn.ImagePullAuth.Token, 205 } 206 } 207 208 files, err := mapFiles(serviceIn.Files) 209 if err != nil { 210 log.Err(err).Str("suite", suiteName).Str("service", serviceIn.Name).Msg("Unable to read source files") 211 return imagerunner.Service{}, err 212 } 213 214 serviceOut := imagerunner.Service{ 215 Name: serviceIn.Name, 216 Container: imagerunner.Container{ 217 Name: serviceIn.Image, 218 Auth: auth, 219 }, 220 221 EntryPoint: serviceIn.EntryPoint, 222 Env: mapEnv(serviceIn.Env), 223 Files: files, 224 } 225 return serviceOut, nil 226 } 227 228 func (r *ImgRunner) runSuite(suite imagerunner.Suite) (imagerunner.Runner, error) { 229 files, err := mapFiles(suite.Files) 230 if err != nil { 231 log.Err(err).Str("suite", suite.Name).Msg("Unable to read source files") 232 return imagerunner.Runner{}, err 233 } 234 235 log.Info(). 236 Str("image", suite.Image). 237 Str("suite", suite.Name). 238 Str("tunnel", r.Project.Sauce.Tunnel.Name). 239 Msg("Starting suite.") 240 241 if suite.Timeout <= 0 { 242 suite.Timeout = 24 * time.Hour 243 } 244 245 ctx, cancel := context.WithTimeout(r.ctx, suite.Timeout) 246 defer cancel() 247 248 var auth *imagerunner.Auth 249 if suite.ImagePullAuth.User != "" && suite.ImagePullAuth.Token != "" { 250 auth = &imagerunner.Auth{ 251 User: suite.ImagePullAuth.User, 252 Token: suite.ImagePullAuth.Token, 253 } 254 } 255 256 services := make([]imagerunner.Service, len(suite.Services)) 257 for i, s := range suite.Services { 258 services[i], err = r.buildService(s, suite.Name) 259 if err != nil { 260 return imagerunner.Runner{}, err 261 } 262 } 263 264 runner, err := r.RunnerService.TriggerRun(ctx, imagerunner.RunnerSpec{ 265 Container: imagerunner.Container{ 266 Name: suite.Image, 267 Auth: auth, 268 }, 269 270 EntryPoint: suite.EntryPoint, 271 Env: mapEnv(suite.Env), 272 Files: files, 273 Artifacts: suite.Artifacts, 274 Metadata: suite.Metadata, 275 WorkloadType: suite.Workload, 276 Tunnel: r.getTunnel(), 277 Services: services, 278 }) 279 280 if errors.Is(err, context.DeadlineExceeded) && ctx.Err() != nil { 281 runner.Status = imagerunner.StateCancelled 282 return runner, SuiteTimeoutError{Timeout: suite.Timeout} 283 } 284 if errors.Is(err, context.Canceled) && ctx.Err() != nil { 285 runner.Status = imagerunner.StateCancelled 286 return runner, ErrSuiteCancelled 287 } 288 if err != nil { 289 runner.Status = imagerunner.StateFailed 290 return runner, err 291 } 292 293 log.Info().Str("image", suite.Image).Str("suite", suite.Name).Str("runID", runner.ID). 294 Msg("Started suite.") 295 296 if r.Async { 297 // Async mode means we don't wait for the suite to finish. 298 return runner, nil 299 } 300 301 go r.streamLiveLogs(ctx, runner) 302 303 var run imagerunner.Runner 304 run, err = r.PollRun(ctx, runner.ID, runner.Status) 305 if errors.Is(err, context.DeadlineExceeded) && ctx.Err() != nil { 306 // Use a new context, because the suite's already timed out, and we'd not be able to stop the run. 307 _ = r.RunnerService.StopRun(context.Background(), runner.ID) 308 run.Status = imagerunner.StateCancelled 309 return run, SuiteTimeoutError{Timeout: suite.Timeout} 310 } 311 if errors.Is(err, context.Canceled) && ctx.Err() != nil { 312 // Use a new context, because saucectl is already interrupted, and we'd not be able to stop the run. 313 _ = r.RunnerService.StopRun(context.Background(), runner.ID) 314 run.Status = imagerunner.StateCancelled 315 return run, ErrSuiteCancelled 316 } 317 if err != nil { 318 return run, err 319 } 320 321 if run.Status != imagerunner.StateSucceeded { 322 return run, fmt.Errorf("suite %q failed: %s", suite.Name, run.TerminationReason) 323 } 324 325 return run, err 326 } 327 328 func (r *ImgRunner) streamLiveLogs(ctx context.Context, runner imagerunner.Runner) { 329 if !r.Project.LiveLogs { 330 return 331 } 332 333 ignoreError := func(err error) bool { 334 if err == nil { 335 return true 336 } 337 if errors.Is(err, context.Canceled) { 338 return true 339 } 340 if strings.Contains(err.Error(), "websocket: close") { 341 return true 342 } 343 return false 344 } 345 346 err := r.RunnerService.StreamLiveLogs(ctx, runner.ID, true) 347 if !ignoreError(err) { 348 log.Err(err).Msg("Async event handler failed.") 349 } 350 } 351 352 func (r *ImgRunner) getTunnel() *imagerunner.Tunnel { 353 if r.Project.Sauce.Tunnel.Name == "" && r.Project.Sauce.Tunnel.Owner == "" { 354 return nil 355 } 356 return &imagerunner.Tunnel{ 357 Name: r.Project.Sauce.Tunnel.Name, 358 Owner: r.Project.Sauce.Tunnel.Owner, 359 } 360 } 361 362 func (r *ImgRunner) collectResults(results chan execResult, expected int) bool { 363 inProgress := expected 364 passed := true 365 366 stopProgress := r.startProgressTicker(r.ctx, &inProgress) 367 for i := 0; i < expected; i++ { 368 res := <-results 369 inProgress-- 370 371 if res.err != nil { 372 passed = false 373 } 374 375 r.PrintResult(res) 376 377 var artifacts []report.Artifact 378 if res.assetsStatus == imagerunner.RunnerAssetStateErrored { 379 log.Warn().Msg("Logs and artifacts are not available due to an error.") 380 } else { 381 if !r.Project.LiveLogs { 382 // only print logs if live logs are disabled 383 r.PrintLogs(res.runID, res.name) 384 } 385 files := r.DownloadArtifacts(res.runID, res.name, res.status, res.err != nil) 386 for _, f := range files { 387 artifacts = append(artifacts, report.Artifact{FilePath: f}) 388 } 389 } 390 391 for _, r := range r.Reporters { 392 r.Add(report.TestResult{ 393 Name: res.name, 394 Duration: res.duration, 395 StartTime: res.startTime, 396 EndTime: res.endTime, 397 Status: res.status, 398 Artifacts: artifacts, 399 Platform: "Linux", 400 RunID: res.runID, 401 Attempts: []report.Attempt{{ 402 ID: res.runID, 403 Duration: res.duration, 404 StartTime: res.startTime, 405 EndTime: res.endTime, 406 Status: res.status, 407 }}, 408 }) 409 } 410 } 411 stopProgress() 412 413 for _, r := range r.Reporters { 414 r.Render() 415 } 416 417 return passed 418 } 419 420 func (r *ImgRunner) registerInterruptOnSignal() chan os.Signal { 421 sigChan := make(chan os.Signal, 1) 422 signal.Notify(sigChan, os.Interrupt) 423 424 go func(c <-chan os.Signal, hr *ImgRunner) { 425 for { 426 sig := <-c 427 if sig == nil { 428 return 429 } 430 if r.ctx.Err() == nil { 431 r.cancel() 432 println("\nStopping run. Cancelling all suites in progress... (press Ctrl-c again to exit without waiting)\n") 433 } else { 434 os.Exit(1) 435 } 436 } 437 }(sigChan, r) 438 return sigChan 439 } 440 441 func (r *ImgRunner) PollRun(ctx context.Context, id string, lastStatus string) (imagerunner.Runner, error) { 442 ticker := time.NewTicker(15 * time.Second) 443 defer ticker.Stop() 444 445 for { 446 select { 447 case <-ctx.Done(): 448 return imagerunner.Runner{}, ctx.Err() 449 case <-ticker.C: 450 r, err := r.RunnerService.GetStatus(ctx, id) 451 if err != nil { 452 return r, err 453 } 454 if r.Status != lastStatus { 455 log.Info().Str("runID", r.ID).Str("old", lastStatus).Str("new", r.Status).Msg("Status change.") 456 lastStatus = r.Status 457 } 458 if imagerunner.Done(r.Status) { 459 return r, err 460 } 461 } 462 } 463 } 464 465 // DownloadArtifacts downloads a zipped archive of artifacts 466 // and extracts the required files. 467 func (r *ImgRunner) DownloadArtifacts(runnerID, suiteName, status string, passed bool) []string { 468 if r.Async || 469 runnerID == "" || 470 status == imagerunner.StateCancelled || 471 !r.Project.Artifacts.Download.When.IsNow(passed) { 472 return nil 473 } 474 475 dir, err := config.GetSuiteArtifactFolder(suiteName, r.Project.Artifacts.Download) 476 if err != nil { 477 log.Err(err).Msg("Unable to create artifacts folder.") 478 return nil 479 } 480 481 log.Info().Msg("Downloading artifacts archive") 482 reader, err := r.RunnerService.DownloadArtifacts(r.ctx, runnerID) 483 if err != nil { 484 log.Err(err).Str("suite", suiteName).Msg("Failed to fetch artifacts.") 485 return nil 486 } 487 defer reader.Close() 488 489 fileName, err := fileio.CreateTemp(reader) 490 if err != nil { 491 log.Err(err).Str("suite", suiteName).Msg("Failed to download artifacts content.") 492 return nil 493 } 494 defer os.Remove(fileName) 495 496 zf, err := zip.OpenReader(fileName) 497 if err != nil { 498 log.Err(err).Msgf("Unable to open zip file %q", fileName) 499 return nil 500 } 501 defer zf.Close() 502 var artifacts []string 503 for _, f := range zf.File { 504 for _, pattern := range r.Project.Artifacts.Download.Match { 505 if glob.Glob(pattern, f.Name) { 506 if err = szip.Extract(dir, f); err != nil { 507 log.Err(err).Msgf("Unable to extract file %q", f.Name) 508 } else { 509 artifacts = append(artifacts, filepath.Join(dir, f.Name)) 510 } 511 break 512 } 513 } 514 } 515 return artifacts 516 } 517 518 func (r *ImgRunner) PrintResult(res execResult) { 519 if r.Async { 520 return 521 } 522 523 logEvent := log.Err(res.err). 524 Str("suite", res.name). 525 Bool("passed", res.err == nil). 526 Str("runID", res.runID) 527 528 if res.err != nil { 529 logEvent.Msg("Suite failed.") 530 return 531 } 532 533 logEvent.Msg("Suite finished.") 534 } 535 536 func (r *ImgRunner) PrintLogs(runID, suiteName string) { 537 if r.Async || runID == "" { 538 return 539 } 540 541 // Need a poll timeout, because artifacts may never exist. 542 ctx, cancel := context.WithTimeout(r.ctx, 3*time.Minute) 543 defer cancel() 544 545 logs, err := r.PollLogs(ctx, runID) 546 if err != nil { 547 log.Err(err).Str("suite", suiteName).Msg("Unable to display logs.") 548 } else { 549 msg.LogConsoleOut(suiteName, logs) 550 } 551 } 552 553 func (r *ImgRunner) PollLogs(ctx context.Context, id string) (string, error) { 554 ticker := time.NewTicker(10 * time.Second) 555 defer ticker.Stop() 556 557 for { 558 select { 559 case <-ctx.Done(): 560 return "", ctx.Err() 561 case <-ticker.C: 562 l, err := r.RunnerService.GetLogs(ctx, id) 563 if err == imagerunner.ErrResourceNotFound || errors.Is(err, context.DeadlineExceeded) { 564 // Keep retrying on 404s or request timeouts. Might be available later. 565 continue 566 } 567 return l, err 568 } 569 } 570 } 571 572 func (r *ImgRunner) getSuiteNames() []string { 573 var names []string 574 for _, s := range r.Project.Suites { 575 names = append(names, s.Name) 576 } 577 return names 578 } 579 580 func mapEnv(env map[string]string) []imagerunner.EnvItem { 581 var items []imagerunner.EnvItem 582 for key, val := range env { 583 items = append(items, imagerunner.EnvItem{ 584 Name: key, 585 Value: val, 586 }) 587 } 588 return items 589 } 590 591 func mapFiles(files []imagerunner.File) ([]imagerunner.FileData, error) { 592 var items []imagerunner.FileData 593 for _, f := range files { 594 data, err := readFile(f.Src) 595 if err != nil { 596 return items, err 597 } 598 items = append(items, imagerunner.FileData{ 599 Path: f.Dst, 600 Data: data, 601 }) 602 } 603 return items, nil 604 } 605 606 func readFile(path string) (string, error) { 607 bytes, err := os.ReadFile(path) 608 if err != nil { 609 return "", err 610 } 611 return base64.StdEncoding.Strict().EncodeToString(bytes), nil 612 } 613 614 func (r *ImgRunner) startProgressTicker(ctx context.Context, progress *int) (cancel context.CancelFunc) { 615 ctx, cancel = context.WithCancel(ctx) 616 617 go func() { 618 t := time.NewTicker(10 * time.Second) 619 defer t.Stop() 620 for { 621 select { 622 case <-ctx.Done(): 623 return 624 case <-t.C: 625 if r.AsyncEventManager.IsLogIdle() { 626 log.Info().Msgf("Suites in progress: %d", *progress) 627 } 628 } 629 } 630 }() 631 632 return 633 } 634 635 // orDefault takes two values of type T and returns a if it's non-zero (not 0, "" etc.), b otherwise. 636 func orDefault[T comparable](a T, b T) T { 637 if reflect.ValueOf(a).IsZero() { 638 return b 639 } 640 641 return a 642 }