1/* 2 * Copyright 2018 Google Inc. 3 * 4 * Use of this source code is governed by a BSD-style license that can be 5 * found in the LICENSE file. 6 */ 7 8package main 9 10import ( 11 "bytes" 12 "context" 13 "encoding/json" 14 "flag" 15 "fmt" 16 "io" 17 "io/ioutil" 18 "net/http" 19 "os" 20 "os/exec" 21 "sort" 22 "strconv" 23 "strings" 24 "syscall" 25 "time" 26 27 "go.skia.org/infra/go/gcs" 28 "go.skia.org/infra/go/httputils" 29 30 "cloud.google.com/go/storage" 31 "google.golang.org/api/option" 32 gstorage "google.golang.org/api/storage/v1" 33 34 "go.skia.org/infra/go/auth" 35 "go.skia.org/infra/go/common" 36 "go.skia.org/infra/go/sklog" 37 "go.skia.org/infra/go/util" 38) 39 40const ( 41 META_DATA_FILENAME = "meta.json" 42) 43 44// Command line flags. 45var ( 46 devicesFile = flag.String("devices", "", "JSON file that maps device ids to versions to run on. Same format as produced by the dump_devices flag.") 47 dryRun = flag.Bool("dryrun", false, "Print out the command and quit without triggering tests.") 48 dumpDevFile = flag.String("dump_devices", "", "Creates a JSON file with all physical devices that are not deprecated.") 49 minAPIVersion = flag.Int("min_api", 0, "Minimum API version required by device.") 50 maxAPIVersion = flag.Int("max_api", 99, "Maximum API version required by device.") 51 properties = flag.String("properties", "", "Custom meta data to be added to the uploaded APK. Comma separated list of key=value pairs, i.e. 'k1=v1,k2=v2,k3=v3.") 52 uploadGCSPath = flag.String("upload_path", "", "GCS path (bucket/path) to where the APK should be uploaded to. It's assume to a full path (not a directory).") 53) 54 55const ( 56 RUN_TESTS_TEMPLATE = `gcloud beta firebase test android run 57 --type=game-loop 58 --app=%s 59 --results-bucket=%s 60 --results-dir=%s 61 --directories-to-pull=/sdcard/Android/data/org.skia.skqp 62 --timeout 30m 63 %s 64` 65 MODEL_VERSION_TMPL = "--device model=%s,version=%s,orientation=portrait" 66 RESULT_BUCKET = "skia-firebase-test-lab" 67 RESULT_DIR_TMPL = "testruns/%s/%s" 68 RUN_ID_TMPL = "testrun-%d" 69 CMD_AVAILABLE_DEVICES = "gcloud firebase test android models list --format json" 70) 71 72func main() { 73 common.Init() 74 75 // Get the path to the APK. It can be empty if we are dumping the device list. 76 apkPath := flag.Arg(0) 77 if *dumpDevFile == "" && apkPath == "" { 78 sklog.Errorf("Missing APK. The APK file needs to be passed as the positional argument.") 79 os.Exit(1) 80 } 81 82 // Get the available devices. 83 fbDevices, deviceList, err := getAvailableDevices() 84 if err != nil { 85 sklog.Fatalf("Error retrieving available devices: %s", err) 86 } 87 88 // Dump the device list and exit. 89 if *dumpDevFile != "" { 90 if err := writeDeviceList(*dumpDevFile, deviceList); err != nil { 91 sklog.Fatalf("Unable to write devices: %s", err) 92 } 93 return 94 } 95 96 // If no devices are explicitly listed. Use all of them. 97 whiteList := deviceList 98 if *devicesFile != "" { 99 whiteList, err = readDeviceList(*devicesFile) 100 if err != nil { 101 sklog.Fatalf("Error reading device file: %s", err) 102 } 103 } 104 105 // Make sure we can authenticate locally and in the cloud. 106 ts, err := auth.NewDefaultTokenSource(true, gstorage.CloudPlatformScope, "https://www.googleapis.com/auth/userinfo.email") 107 if err != nil { 108 sklog.Fatal(err) 109 } 110 client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client() 111 112 // Filter the devices according the white list and other parameters. 113 devices, ignoredDevices := filterDevices(fbDevices, whiteList, *minAPIVersion, *maxAPIVersion) 114 sklog.Infof("---\nSelected devices:") 115 logDevices(devices) 116 117 if len(devices) == 0 { 118 sklog.Errorf("No devices selected. Not running tests.") 119 os.Exit(1) 120 } 121 122 if err := runTests(apkPath, devices, ignoredDevices, client, *dryRun); err != nil { 123 sklog.Fatalf("Error running tests on Firebase: %s", err) 124 } 125 126 if !*dryRun && (*uploadGCSPath != "") && (*properties != "") { 127 if err := uploadAPK(apkPath, *uploadGCSPath, *properties, client); err != nil { 128 sklog.Fatalf("Error uploading APK to '%s': %s", *uploadGCSPath, err) 129 } 130 } 131} 132 133// getAvailableDevices queries Firebase Testlab for all physical devices that 134// are not deprecated. It returns two lists with the same information. 135// The first contains all device information as returned by Firebase while 136// the second contains the information necessary to use in a whitelist. 137func getAvailableDevices() ([]*DeviceVersions, DeviceList, error) { 138 // Get the list of all devices in JSON format from Firebase testlab. 139 var buf bytes.Buffer 140 var errBuf bytes.Buffer 141 cmd := parseCommand(CMD_AVAILABLE_DEVICES) 142 cmd.Stdout = &buf 143 cmd.Stderr = io.MultiWriter(os.Stdout, &errBuf) 144 if err := cmd.Run(); err != nil { 145 return nil, nil, sklog.FmtErrorf("Error running: %s\nError:%s\nStdErr:%s", CMD_AVAILABLE_DEVICES, err, errBuf) 146 } 147 148 // Unmarshal the result. 149 foundDevices := []*DeviceVersions{} 150 bufBytes := buf.Bytes() 151 if err := json.Unmarshal(bufBytes, &foundDevices); err != nil { 152 return nil, nil, sklog.FmtErrorf("Unmarshal of device information failed: %s \nJSON Input: %s\n", err, string(bufBytes)) 153 } 154 155 // Filter the devices and copy them to device list. 156 devList := DeviceList{} 157 ret := make([]*DeviceVersions, 0, len(foundDevices)) 158 for _, foundDev := range foundDevices { 159 // Only consider physical devices and devices that are not deprecated. 160 if (foundDev.Form == "PHYSICAL") && !util.In("deprecated", foundDev.Tags) { 161 ret = append(ret, foundDev) 162 devList = append(devList, &DevInfo{ 163 ID: foundDev.ID, 164 Name: foundDev.Name, 165 RunVersions: foundDev.VersionIDs, 166 }) 167 } 168 } 169 return foundDevices, devList, nil 170} 171 172// filterDevices filters the given devices by ensuring that they are in the white list 173// and within the given api version range. 174// It returns two lists: (accepted_devices, ignored_devices) 175func filterDevices(foundDevices []*DeviceVersions, whiteList DeviceList, minAPIVersion, maxAPIVersion int) ([]*DeviceVersions, []*DeviceVersions) { 176 // iterate over the available devices and partition them. 177 allDevices := make([]*DeviceVersions, 0, len(foundDevices)) 178 ret := make([]*DeviceVersions, 0, len(foundDevices)) 179 ignored := make([]*DeviceVersions, 0, len(foundDevices)) 180 for _, dev := range foundDevices { 181 // Only include devices that are on the whitelist and have versions defined. 182 if targetDev := whiteList.find(dev.ID); targetDev != nil && (len(targetDev.RunVersions) > 0) { 183 versionSet := util.NewStringSet(dev.VersionIDs) 184 reqVersions := util.NewStringSet(filterVersions(targetDev.RunVersions, minAPIVersion, maxAPIVersion)) 185 whiteListVersions := versionSet.Intersect(reqVersions).Keys() 186 ignoredVersions := versionSet.Complement(reqVersions).Keys() 187 sort.Strings(whiteListVersions) 188 sort.Strings(ignoredVersions) 189 if len(whiteListVersions) > 0 { 190 ret = append(ret, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: whiteListVersions}) 191 } 192 if len(ignoredVersions) > 0 { 193 ignored = append(ignored, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: ignoredVersions}) 194 } 195 } else { 196 ignored = append(ignored, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: dev.VersionIDs}) 197 } 198 allDevices = append(allDevices, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: dev.VersionIDs}) 199 } 200 201 sklog.Infof("All devices:") 202 logDevices(allDevices) 203 204 return ret, ignored 205} 206 207// filterVersions returns the elements in versionIDs where minVersion <= element <= maxVersion. 208func filterVersions(versionIDs []string, minVersion, maxVersion int) []string { 209 ret := make([]string, 0, len(versionIDs)) 210 for _, versionID := range versionIDs { 211 id, err := strconv.Atoi(versionID) 212 if err != nil { 213 sklog.Fatalf("Error parsing version id '%s': %s", versionID, err) 214 } 215 if (id >= minVersion) && (id <= maxVersion) { 216 ret = append(ret, versionID) 217 } 218 } 219 return ret 220} 221 222// runTests runs the given apk on the given list of devices. 223func runTests(apk_path string, devices, ignoredDevices []*DeviceVersions, client *http.Client, dryRun bool) error { 224 // Get the model-version we want to test. Assume on average each model has 5 supported versions. 225 modelSelectors := make([]string, 0, len(devices)*5) 226 for _, devRec := range devices { 227 for _, version := range devRec.RunVersions { 228 modelSelectors = append(modelSelectors, fmt.Sprintf(MODEL_VERSION_TMPL, devRec.FirebaseDevice.ID, version)) 229 } 230 } 231 232 now := time.Now() 233 nowMs := now.UnixNano() / int64(time.Millisecond) 234 runID := fmt.Sprintf(RUN_ID_TMPL, nowMs) 235 resultsDir := fmt.Sprintf(RESULT_DIR_TMPL, now.Format("2006/01/02/15"), runID) 236 cmdStr := fmt.Sprintf(RUN_TESTS_TEMPLATE, apk_path, RESULT_BUCKET, resultsDir, strings.Join(modelSelectors, "\n")) 237 cmdStr = strings.TrimSpace(strings.Replace(cmdStr, "\n", " ", -1)) 238 239 // Run the command. 240 var errBuf bytes.Buffer 241 cmd := parseCommand(cmdStr) 242 cmd.Stdout = os.Stdout 243 cmd.Stderr = io.MultiWriter(os.Stdout, &errBuf) 244 exitCode := 0 245 246 if dryRun { 247 fmt.Printf("[dry run]: Would have run this command: %s\n", cmdStr) 248 return nil 249 } 250 251 if err := cmd.Run(); err != nil { 252 // Get the exit code. 253 if exitError, ok := err.(*exec.ExitError); ok { 254 ws := exitError.Sys().(syscall.WaitStatus) 255 exitCode = ws.ExitStatus() 256 } 257 258 sklog.Errorf("Error running tests: %s", err) 259 sklog.Errorf("Exit code: %d", exitCode) 260 261 // Exit code 10 means triggering on Testlab succeeded, but but some of the 262 // runs on devices failed. We consider it a success for this script. 263 if exitCode != 10 { 264 return sklog.FmtErrorf("Error running: %s\nError:%s\nStdErr:%s", cmdStr, err, errBuf) 265 } 266 } 267 268 // Store the result in a meta json file. 269 meta := &TestRunMeta{ 270 ID: runID, 271 TS: nowMs, 272 Devices: devices, 273 IgnoredDevices: ignoredDevices, 274 ExitCode: exitCode, 275 } 276 277 targetPath := fmt.Sprintf("%s/%s/%s", RESULT_BUCKET, resultsDir, META_DATA_FILENAME) 278 if err := meta.writeToGCS(targetPath, client); err != nil { 279 return err 280 } 281 sklog.Infof("Meta data written to gs://%s", targetPath) 282 return nil 283} 284 285// uploadAPK uploads the APK at the given path to the bucket/path in gcsPath. 286// The key-value pairs in propStr are set as custom meta data of the APK. 287func uploadAPK(apkPath, gcsPath, propStr string, client *http.Client) error { 288 properties, err := splitProperties(propStr) 289 if err != nil { 290 return err 291 } 292 apkFile, err := os.Open(apkPath) 293 if err != nil { 294 return err 295 } 296 defer util.Close(apkFile) 297 298 if err := copyReaderToGCS(gcsPath, apkFile, client, "application/vnd.android.package-archive", properties, true, false); err != nil { 299 return err 300 } 301 302 sklog.Infof("APK uploaded to gs://%s", gcsPath) 303 return nil 304} 305 306// splitProperties receives a comma separated list of 'key=value' pairs and 307// returnes them as a map. 308func splitProperties(propStr string) (map[string]string, error) { 309 splitProps := strings.Split(propStr, ",") 310 properties := make(map[string]string, len(splitProps)) 311 for _, oneProp := range splitProps { 312 kv := strings.Split(oneProp, "=") 313 if len(kv) != 2 { 314 return nil, sklog.FmtErrorf("Inavlid porperties format. Unable to parse '%s'", propStr) 315 } 316 properties[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) 317 } 318 return properties, nil 319} 320 321// logDevices logs the given list of devices. 322func logDevices(devices []*DeviceVersions) { 323 sklog.Infof("Found %d devices.", len(devices)) 324 for _, dev := range devices { 325 fbDev := dev.FirebaseDevice 326 sklog.Infof("%-15s %-30s %v / %v", fbDev.ID, fbDev.Name, fbDev.VersionIDs, dev.RunVersions) 327 } 328} 329 330// parseCommad parses a command line and wraps it in an exec.Command instance. 331func parseCommand(cmdStr string) *exec.Cmd { 332 cmdArgs := strings.Split(strings.TrimSpace(cmdStr), " ") 333 for idx := range cmdArgs { 334 cmdArgs[idx] = strings.TrimSpace(cmdArgs[idx]) 335 } 336 return exec.Command(cmdArgs[0], cmdArgs[1:]...) 337} 338 339// DeviceList is a simple list of devices, primarily used to define the 340// whitelist of devices we want to run on. 341type DeviceList []*DevInfo 342 343type DevInfo struct { 344 ID string `json:"id"` 345 Name string `json:"name"` 346 RunVersions []string `json:"runVersions"` 347} 348 349func (d DeviceList) find(id string) *DevInfo { 350 for _, devInfo := range d { 351 if devInfo.ID == id { 352 return devInfo 353 } 354 } 355 return nil 356} 357 358func writeDeviceList(fileName string, devList DeviceList) error { 359 jsonBytes, err := json.MarshalIndent(devList, "", " ") 360 if err != nil { 361 return sklog.FmtErrorf("Unable to encode JSON: %s", err) 362 } 363 364 if err := ioutil.WriteFile(fileName, jsonBytes, 0644); err != nil { 365 sklog.FmtErrorf("Unable to write file '%s': %s", fileName, err) 366 } 367 return nil 368} 369 370func readDeviceList(fileName string) (DeviceList, error) { 371 inFile, err := os.Open(fileName) 372 if err != nil { 373 return nil, sklog.FmtErrorf("Unable to open file '%s': %s", fileName, err) 374 } 375 defer util.Close(inFile) 376 377 var devList DeviceList 378 if err := json.NewDecoder(inFile).Decode(&devList); err != nil { 379 return nil, sklog.FmtErrorf("Unable to decode JSON from '%s': %s", fileName, err) 380 } 381 return devList, nil 382} 383 384// FirebaseDevice contains the information and JSON tags for device information 385// returned by firebase. 386type FirebaseDevice struct { 387 Brand string `json:"brand"` 388 Form string `json:"form"` 389 ID string `json:"id"` 390 Manufacturer string `json:"manufacturer"` 391 Name string `json:"name"` 392 VersionIDs []string `json:"supportedVersionIds"` 393 Tags []string `json:"tags"` 394} 395 396// DeviceVersions combines device information from Firebase Testlab with 397// a selected list of versions. This is used to define a subset of versions 398// used by a devices. 399type DeviceVersions struct { 400 *FirebaseDevice 401 402 // RunVersions contains the version ids of interest contained in Device. 403 RunVersions []string 404} 405 406// TestRunMeta contains the meta data of a complete testrun on firebase. 407type TestRunMeta struct { 408 ID string `json:"id"` 409 TS int64 `json:"timeStamp"` 410 Devices []*DeviceVersions `json:"devices"` 411 IgnoredDevices []*DeviceVersions `json:"ignoredDevices"` 412 ExitCode int `json:"exitCode"` 413} 414 415// writeToGCS writes the meta data as JSON to the given bucket and path in 416// GCS. It assumes that the provided client has permissions to write to the 417// specified location in GCS. 418func (t *TestRunMeta) writeToGCS(gcsPath string, client *http.Client) error { 419 jsonBytes, err := json.Marshal(t) 420 if err != nil { 421 return err 422 } 423 return copyReaderToGCS(gcsPath, bytes.NewReader(jsonBytes), client, "", nil, false, true) 424} 425 426// TODO(stephana): Merge copyReaderToGCS into the go/gcs in 427// the infra repository. 428 429// copyReaderToGCS reads all available content from the given reader and writes 430// it to the given path in GCS. 431func copyReaderToGCS(gcsPath string, reader io.Reader, client *http.Client, contentType string, metaData map[string]string, public bool, gzip bool) error { 432 storageClient, err := storage.NewClient(context.Background(), option.WithHTTPClient(client)) 433 if err != nil { 434 return err 435 } 436 bucket, path := gcs.SplitGSPath(gcsPath) 437 w := storageClient.Bucket(bucket).Object(path).NewWriter(context.Background()) 438 439 // Set the content if requested. 440 if contentType != "" { 441 w.ObjectAttrs.ContentType = contentType 442 } 443 444 // Set the meta data if requested 445 if metaData != nil { 446 w.Metadata = metaData 447 } 448 449 // Make the object public if requested. 450 if public { 451 w.ACL = []storage.ACLRule{{Entity: storage.AllUsers, Role: storage.RoleReader}} 452 } 453 454 // Write the everything the reader can provide to the GCS object. Either 455 // gzip'ed or plain. 456 if gzip { 457 w.ObjectAttrs.ContentEncoding = "gzip" 458 err = util.WithGzipWriter(w, func(w io.Writer) error { 459 _, err := io.Copy(w, reader) 460 return err 461 }) 462 } else { 463 _, err = io.Copy(w, reader) 464 } 465 466 // Make sure we return an error when we close the remote object. 467 if err != nil { 468 _ = w.CloseWithError(err) 469 return err 470 } 471 return w.Close() 472} 473