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 }