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  }