github.com/saucelabs/saucectl@v0.175.1/internal/http/resto.go (about)

     1  package http
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"errors"
     7  	"fmt"
     8  	"io"
     9  	"net/http"
    10  	"os"
    11  	"path/filepath"
    12  	"reflect"
    13  	"sort"
    14  	"strings"
    15  	"time"
    16  
    17  	"github.com/hashicorp/go-retryablehttp"
    18  	"github.com/rs/zerolog/log"
    19  	"github.com/ryanuber/go-glob"
    20  
    21  	"github.com/saucelabs/saucectl/internal/build"
    22  	"github.com/saucelabs/saucectl/internal/config"
    23  	"github.com/saucelabs/saucectl/internal/job"
    24  	tunnels "github.com/saucelabs/saucectl/internal/tunnel"
    25  	"github.com/saucelabs/saucectl/internal/vmd"
    26  )
    27  
    28  type restoJob struct {
    29  	ID                  string `json:"id"`
    30  	Name                string `json:"name"`
    31  	Passed              bool   `json:"passed"`
    32  	Status              string `json:"status"`
    33  	Error               string `json:"error"`
    34  	Browser             string `json:"browser"`
    35  	BrowserShortVersion string `json:"browser_short_version"`
    36  	BaseConfig          struct {
    37  		DeviceName string `json:"deviceName"`
    38  		// PlatformName is a complex field that requires judicious treatment.
    39  		//  Observed cases:
    40  		//  - Simulators (iOS): "iOS"
    41  		//  - Emulators (Android): "Linux"
    42  		//  - VMs (Windows/Mac): "Windows 11" or "mac 12"
    43  		PlatformName string `json:"platformName"`
    44  
    45  		// PlatformVersion refers to the OS version and is only populated for
    46  		// simulators.
    47  		PlatformVersion string `json:"platformVersion"`
    48  	} `json:"base_config"`
    49  	AutomationBackend string `json:"automation_backend"`
    50  
    51  	// OS is a combination of the VM's OS name and version. Version is optional.
    52  	OS string `json:"os"`
    53  }
    54  
    55  // Resto http client.
    56  type Resto struct {
    57  	Client         *retryablehttp.Client
    58  	URL            string
    59  	Username       string
    60  	AccessKey      string
    61  	ArtifactConfig config.ArtifactDownload
    62  }
    63  
    64  type tunnel struct {
    65  	ID       string `json:"id"`
    66  	Owner    string `json:"owner"`
    67  	Status   string `json:"status"` // 'new', 'booting', 'deploying', 'halting', 'running', 'terminated'
    68  	TunnelID string `json:"tunnel_identifier"`
    69  }
    70  
    71  // NewResto creates a new client.
    72  func NewResto(url, username, accessKey string, timeout time.Duration) Resto {
    73  	return Resto{
    74  		Client:    NewRetryableClient(timeout),
    75  		URL:       url,
    76  		Username:  username,
    77  		AccessKey: accessKey,
    78  	}
    79  }
    80  
    81  // ReadJob returns the job details.
    82  func (c *Resto) ReadJob(ctx context.Context, id string, realDevice bool) (job.Job, error) {
    83  	if realDevice {
    84  		return job.Job{}, errors.New("the VDC client does not support real device jobs")
    85  	}
    86  
    87  	req, err := NewRequestWithContext(ctx, http.MethodGet,
    88  		fmt.Sprintf("%s/rest/v1.1/%s/jobs/%s", c.URL, c.Username, id), nil)
    89  	if err != nil {
    90  		return job.Job{}, err
    91  	}
    92  
    93  	req.Header.Set("Content-Type", "application/json")
    94  	req.SetBasicAuth(c.Username, c.AccessKey)
    95  
    96  	rreq, err := retryablehttp.FromRequest(req)
    97  	if err != nil {
    98  		return job.Job{}, err
    99  	}
   100  	resp, err := c.Client.Do(rreq)
   101  	if err != nil {
   102  		return job.Job{}, err
   103  	}
   104  	defer resp.Body.Close()
   105  
   106  	if resp.StatusCode >= http.StatusInternalServerError {
   107  		return job.Job{}, ErrServerError
   108  	}
   109  
   110  	if resp.StatusCode == http.StatusNotFound {
   111  		return job.Job{}, ErrJobNotFound
   112  	}
   113  
   114  	if resp.StatusCode != http.StatusOK {
   115  		body, _ := io.ReadAll(resp.Body)
   116  		err := fmt.Errorf("job status request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body))
   117  		return job.Job{}, err
   118  	}
   119  
   120  	return c.parseJob(resp.Body)
   121  }
   122  
   123  // PollJob polls job details at an interval, until timeout has been reached or until the job has ended, whether successfully or due to an error.
   124  func (c *Resto) PollJob(ctx context.Context, id string, interval, timeout time.Duration, realDevice bool) (job.Job, error) {
   125  	if realDevice {
   126  		return job.Job{}, errors.New("the VDC client does not support real device jobs")
   127  	}
   128  
   129  	ticker := time.NewTicker(interval)
   130  	defer ticker.Stop()
   131  
   132  	if timeout <= 0 {
   133  		timeout = 24 * time.Hour
   134  	}
   135  	deathclock := time.NewTimer(timeout)
   136  	defer deathclock.Stop()
   137  
   138  	for {
   139  		select {
   140  		case <-ticker.C:
   141  			j, err := c.ReadJob(ctx, id, realDevice)
   142  			if err != nil {
   143  				return job.Job{}, err
   144  			}
   145  
   146  			if job.Done(j.Status) {
   147  				return j, nil
   148  			}
   149  		case <-deathclock.C:
   150  			j, err := c.ReadJob(ctx, id, realDevice)
   151  			if err != nil {
   152  				return job.Job{}, err
   153  			}
   154  			j.TimedOut = true
   155  			return j, nil
   156  		}
   157  	}
   158  }
   159  
   160  // GetJobAssetFileNames return the job assets list.
   161  func (c *Resto) GetJobAssetFileNames(ctx context.Context, jobID string, realDevice bool) ([]string, error) {
   162  	if realDevice {
   163  		return nil, errors.New("the VDC client does not support real device jobs")
   164  	}
   165  
   166  	req, err := NewRequestWithContext(ctx, http.MethodGet,
   167  		fmt.Sprintf("%s/rest/v1/%s/jobs/%s/assets", c.URL, c.Username, jobID), nil)
   168  	if err != nil {
   169  		return nil, err
   170  	}
   171  
   172  	req.SetBasicAuth(c.Username, c.AccessKey)
   173  
   174  	rreq, err := retryablehttp.FromRequest(req)
   175  	if err != nil {
   176  		return nil, err
   177  	}
   178  	resp, err := c.Client.Do(rreq)
   179  	if err != nil {
   180  		return nil, err
   181  	}
   182  	defer resp.Body.Close()
   183  
   184  	if resp.StatusCode >= http.StatusInternalServerError {
   185  		return nil, ErrServerError
   186  	}
   187  
   188  	if resp.StatusCode == http.StatusNotFound {
   189  		return nil, ErrJobNotFound
   190  	}
   191  
   192  	if resp.StatusCode != http.StatusOK {
   193  		body, _ := io.ReadAll(resp.Body)
   194  		err := fmt.Errorf("job assets list request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body))
   195  		return nil, err
   196  	}
   197  
   198  	var filesMap map[string]interface{}
   199  	if err := json.NewDecoder(resp.Body).Decode(&filesMap); err != nil {
   200  		return []string{}, err
   201  	}
   202  
   203  	var filesList []string
   204  	for k, v := range filesMap {
   205  		if k == "video" || k == "screenshots" {
   206  			continue
   207  		}
   208  
   209  		if v != nil && reflect.TypeOf(v).Name() == "string" {
   210  			filesList = append(filesList, v.(string))
   211  		}
   212  	}
   213  	return filesList, nil
   214  }
   215  
   216  // GetJobAssetFileContent returns the job asset file content.
   217  func (c *Resto) GetJobAssetFileContent(ctx context.Context, jobID, fileName string, realDevice bool) ([]byte, error) {
   218  	if realDevice {
   219  		return nil, errors.New("the VDC client does not support real device jobs")
   220  	}
   221  
   222  	req, err := NewRequestWithContext(ctx, http.MethodGet,
   223  		fmt.Sprintf("%s/rest/v1/%s/jobs/%s/assets/%s", c.URL, c.Username, jobID, fileName), nil)
   224  	if err != nil {
   225  		return nil, err
   226  	}
   227  
   228  	req.SetBasicAuth(c.Username, c.AccessKey)
   229  
   230  	rreq, err := retryablehttp.FromRequest(req)
   231  	if err != nil {
   232  		return nil, err
   233  	}
   234  
   235  	resp, err := c.Client.Do(rreq)
   236  	if err != nil {
   237  		return nil, err
   238  	}
   239  	defer resp.Body.Close()
   240  
   241  	if resp.StatusCode >= http.StatusInternalServerError {
   242  		return nil, ErrServerError
   243  	}
   244  	if resp.StatusCode == http.StatusNotFound {
   245  		return nil, ErrAssetNotFound
   246  	}
   247  
   248  	if resp.StatusCode != http.StatusOK {
   249  		body, _ := io.ReadAll(resp.Body)
   250  		err := fmt.Errorf("job status request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body))
   251  		return nil, err
   252  	}
   253  
   254  	return io.ReadAll(resp.Body)
   255  }
   256  
   257  // IsTunnelRunning checks whether tunnelID is running. If not, it will wait for the tunnel to become available or
   258  // timeout. Whichever comes first.
   259  func (c *Resto) IsTunnelRunning(ctx context.Context, id, owner string, filter tunnels.Filter, wait time.Duration) error {
   260  	deathclock := time.Now().Add(wait)
   261  	var err error
   262  	for time.Now().Before(deathclock) {
   263  		if err = c.isTunnelRunning(ctx, id, owner, filter); err == nil {
   264  			return nil
   265  		}
   266  		time.Sleep(1 * time.Second)
   267  	}
   268  
   269  	return err
   270  }
   271  
   272  func (c *Resto) isTunnelRunning(ctx context.Context, id, owner string, filter tunnels.Filter) error {
   273  	req, err := NewRequestWithContext(ctx, http.MethodGet,
   274  		fmt.Sprintf("%s/rest/v1/%s/tunnels", c.URL, c.Username), nil)
   275  	if err != nil {
   276  		return err
   277  	}
   278  	req.SetBasicAuth(c.Username, c.AccessKey)
   279  
   280  	q := req.URL.Query()
   281  	q.Add("full", "true")
   282  	q.Add("all", "true")
   283  
   284  	if filter != "" {
   285  		q.Add("filter", string(filter))
   286  	}
   287  	req.URL.RawQuery = q.Encode()
   288  
   289  	r, err := retryablehttp.FromRequest(req)
   290  	if err != nil {
   291  		return err
   292  	}
   293  
   294  	res, err := c.Client.Do(r)
   295  	if err != nil {
   296  		return err
   297  	}
   298  	if res.StatusCode != http.StatusOK {
   299  		body, _ := io.ReadAll(res.Body)
   300  		err := fmt.Errorf("tunnel request failed; unexpected response code:'%d', msg:'%v'", res.StatusCode, string(body))
   301  		return err
   302  	}
   303  
   304  	var resp map[string][]tunnel
   305  	if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
   306  		return err
   307  	}
   308  
   309  	// Owner should be the current user or the defined parent if there is one.
   310  	if owner == "" {
   311  		owner = c.Username
   312  	}
   313  
   314  	for _, tt := range resp {
   315  		for _, t := range tt {
   316  			// User could be using tunnel name (aka tunnel_identifier) or the tunnel ID. Make sure we check both.
   317  			if t.TunnelID != id && t.ID != id {
   318  				continue
   319  			}
   320  			if t.Owner != owner {
   321  				continue
   322  			}
   323  			if t.Status == "running" {
   324  				return nil
   325  			}
   326  		}
   327  	}
   328  	return ErrTunnelNotFound
   329  }
   330  
   331  // StopJob stops the job on the Sauce Cloud.
   332  func (c *Resto) StopJob(ctx context.Context, jobID string, realDevice bool) (job.Job, error) {
   333  	if realDevice {
   334  		return job.Job{}, errors.New("the VDC client does not support real device jobs")
   335  	}
   336  
   337  	req, err := NewRequestWithContext(ctx, http.MethodPut,
   338  		fmt.Sprintf("%s/rest/v1/%s/jobs/%s/stop", c.URL, c.Username, jobID), nil)
   339  	if err != nil {
   340  		return job.Job{}, err
   341  	}
   342  
   343  	req.Header.Set("Content-Type", "application/json")
   344  	req.SetBasicAuth(c.Username, c.AccessKey)
   345  
   346  	rreq, err := retryablehttp.FromRequest(req)
   347  	if err != nil {
   348  		return job.Job{}, err
   349  	}
   350  	resp, err := c.Client.Do(rreq)
   351  	if err != nil {
   352  		return job.Job{}, err
   353  	}
   354  	defer resp.Body.Close()
   355  
   356  	if resp.StatusCode >= http.StatusInternalServerError {
   357  		return job.Job{}, ErrServerError
   358  	}
   359  
   360  	if resp.StatusCode == http.StatusNotFound {
   361  		return job.Job{}, ErrJobNotFound
   362  	}
   363  
   364  	if resp.StatusCode != http.StatusOK {
   365  		body, _ := io.ReadAll(resp.Body)
   366  		err := fmt.Errorf("job status request failed; unexpected response code:'%d', msg:'%v'", resp.StatusCode, string(body))
   367  		return job.Job{}, err
   368  	}
   369  
   370  	return c.parseJob(resp.Body)
   371  }
   372  
   373  // DownloadArtifact downloads artifacts and returns a list of what was downloaded.
   374  func (c *Resto) DownloadArtifact(jobID, suiteName string, realDevice bool) []string {
   375  	targetDir, err := config.GetSuiteArtifactFolder(suiteName, c.ArtifactConfig)
   376  	if err != nil {
   377  		log.Error().Msgf("Unable to create artifacts folder (%v)", err)
   378  		return []string{}
   379  	}
   380  	files, err := c.GetJobAssetFileNames(context.Background(), jobID, realDevice)
   381  	if err != nil {
   382  		log.Error().Msgf("Unable to fetch artifacts list (%v)", err)
   383  		return []string{}
   384  	}
   385  	var artifacts []string
   386  	for _, f := range files {
   387  		for _, pattern := range c.ArtifactConfig.Match {
   388  			if glob.Glob(pattern, f) {
   389  				if err := c.downloadArtifact(targetDir, jobID, f); err != nil {
   390  					log.Error().Err(err).Msgf("Failed to download file: %s", f)
   391  				} else {
   392  					artifacts = append(artifacts, filepath.Join(targetDir, f))
   393  				}
   394  				break
   395  			}
   396  		}
   397  	}
   398  	return artifacts
   399  }
   400  
   401  func (c *Resto) downloadArtifact(targetDir, jobID, fileName string) error {
   402  	content, err := c.GetJobAssetFileContent(context.Background(), jobID, fileName, false)
   403  	if err != nil {
   404  		return err
   405  	}
   406  	targetFile := filepath.Join(targetDir, fileName)
   407  	return os.WriteFile(targetFile, content, 0644)
   408  }
   409  
   410  // GetVirtualDevices returns the list of available virtual devices.
   411  func (c *Resto) GetVirtualDevices(ctx context.Context, kind string) ([]vmd.VirtualDevice, error) {
   412  	req, err := NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/rest/v1.1/info/platforms/all", c.URL), nil)
   413  	if err != nil {
   414  		return nil, err
   415  	}
   416  	req.SetBasicAuth(c.Username, c.AccessKey)
   417  
   418  	r, err := retryablehttp.FromRequest(req)
   419  	if err != nil {
   420  		return []vmd.VirtualDevice{}, err
   421  	}
   422  
   423  	res, err := c.Client.Do(r)
   424  	if err != nil {
   425  		return []vmd.VirtualDevice{}, err
   426  	}
   427  
   428  	var resp []struct {
   429  		LongName     string `json:"long_name"`
   430  		ShortVersion string `json:"short_version"`
   431  	}
   432  	if err := json.NewDecoder(res.Body).Decode(&resp); err != nil {
   433  		return []vmd.VirtualDevice{}, err
   434  	}
   435  
   436  	key := "Emulator"
   437  	if kind == vmd.IOSSimulator {
   438  		key = "Simulator"
   439  	}
   440  
   441  	devs := map[string]map[string]bool{}
   442  	for _, d := range resp {
   443  		if !strings.Contains(d.LongName, key) {
   444  			continue
   445  		}
   446  		if _, ok := devs[d.LongName]; !ok {
   447  			devs[d.LongName] = map[string]bool{}
   448  		}
   449  		devs[d.LongName][d.ShortVersion] = true
   450  	}
   451  
   452  	var dev []vmd.VirtualDevice
   453  	for vmdName, versions := range devs {
   454  		d := vmd.VirtualDevice{Name: vmdName}
   455  		for version := range versions {
   456  			d.OSVersion = append(d.OSVersion, version)
   457  		}
   458  		sort.Strings(d.OSVersion)
   459  		dev = append(dev, d)
   460  	}
   461  	sort.Slice(dev, func(i, j int) bool {
   462  		return dev[i].Name < dev[j].Name
   463  	})
   464  	return dev, nil
   465  }
   466  
   467  func (c *Resto) GetBuildID(ctx context.Context, jobID string, buildSource build.Source) (string, error) {
   468  	req, err := NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/v2/builds/%s/jobs/%s/build/", c.URL, buildSource, jobID), nil)
   469  	if err != nil {
   470  		return "", err
   471  	}
   472  	req.SetBasicAuth(c.Username, c.AccessKey)
   473  
   474  	r, err := retryablehttp.FromRequest(req)
   475  	if err != nil {
   476  		return "", err
   477  	}
   478  
   479  	resp, err := c.Client.Do(r)
   480  	if err != nil {
   481  		return "", err
   482  	}
   483  	defer resp.Body.Close()
   484  
   485  	if resp.StatusCode != 200 {
   486  		return "", fmt.Errorf("unexpected statusCode: %v", resp.StatusCode)
   487  	}
   488  
   489  	var br build.Build
   490  	if err := json.NewDecoder(resp.Body).Decode(&br); err != nil {
   491  		return "", err
   492  	}
   493  
   494  	return br.ID, nil
   495  }
   496  
   497  // parseJob parses the body into restoJob and converts it to job.Job.
   498  func (c *Resto) parseJob(body io.ReadCloser) (job.Job, error) {
   499  	var j restoJob
   500  	if err := json.NewDecoder(body).Decode(&j); err != nil {
   501  		return job.Job{}, err
   502  	}
   503  
   504  	osName := j.BaseConfig.PlatformName
   505  	osVersion := j.BaseConfig.PlatformVersion
   506  
   507  	// PlatformVersion is only populated for simulators. For emulators and VMs,
   508  	// we shall parse the OS field.
   509  	if osVersion == "" {
   510  		segments := strings.Split(j.OS, " ")
   511  		osName = segments[0]
   512  		if len(segments) > 1 {
   513  			osVersion = segments[1]
   514  		}
   515  	}
   516  
   517  	return job.Job{
   518  		ID:             j.ID,
   519  		Name:           j.Name,
   520  		Passed:         j.Passed,
   521  		Status:         j.Status,
   522  		Error:          j.Error,
   523  		BrowserName:    j.Browser,
   524  		BrowserVersion: j.BrowserShortVersion,
   525  		DeviceName:     j.BaseConfig.DeviceName,
   526  		Framework:      j.AutomationBackend,
   527  		OS:             osName,
   528  		OSVersion:      osVersion,
   529  	}, nil
   530  }