| /* |
| * Copyright 2018 Google Inc. |
| * |
| * Use of this source code is governed by a BSD-style license that can be |
| * found in the LICENSE file. |
| */ |
| |
| package main |
| |
| import ( |
| "bytes" |
| "context" |
| "encoding/json" |
| "flag" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "net/http" |
| "os" |
| "os/exec" |
| "sort" |
| "strconv" |
| "strings" |
| "syscall" |
| "time" |
| |
| "go.skia.org/infra/go/gcs" |
| |
| "cloud.google.com/go/storage" |
| "google.golang.org/api/option" |
| gstorage "google.golang.org/api/storage/v1" |
| |
| "go.skia.org/infra/go/auth" |
| "go.skia.org/infra/go/common" |
| "go.skia.org/infra/go/sklog" |
| "go.skia.org/infra/go/util" |
| ) |
| |
| const ( |
| META_DATA_FILENAME = "meta.json" |
| ) |
| |
| // Command line flags. |
| var ( |
| devicesFile = flag.String("devices", "", "JSON file that maps device ids to versions to run on. Same format as produced by the dump_devices flag.") |
| dryRun = flag.Bool("dryrun", false, "Print out the command and quit without triggering tests.") |
| dumpDevFile = flag.String("dump_devices", "", "Creates a JSON file with all physical devices that are not deprecated.") |
| minAPIVersion = flag.Int("min_api", 0, "Minimum API version required by device.") |
| maxAPIVersion = flag.Int("max_api", 99, "Maximum API version required by device.") |
| 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.") |
| serviceAccountFile = flag.String("service_account_file", "", "Credentials file for service account.") |
| 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).") |
| ) |
| |
| const ( |
| RUN_TESTS_TEMPLATE = `gcloud beta firebase test android run |
| --type=game-loop |
| --app=%s |
| --results-bucket=%s |
| --results-dir=%s |
| --directories-to-pull=/sdcard/Android/data/org.skia.skqp |
| --timeout 30m |
| %s |
| ` |
| MODEL_VERSION_TMPL = "--device model=%s,version=%s,orientation=portrait" |
| RESULT_BUCKET = "skia-firebase-test-lab" |
| RESULT_DIR_TMPL = "testruns/%s/%s" |
| RUN_ID_TMPL = "testrun-%d" |
| CMD_AVAILABLE_DEVICES = "gcloud firebase test android models list --format json" |
| ) |
| |
| func main() { |
| common.Init() |
| |
| // Get the path to the APK. It can be empty if we are dumping the device list. |
| apkPath := flag.Arg(0) |
| if *dumpDevFile == "" && apkPath == "" { |
| sklog.Errorf("Missing APK. The APK file needs to be passed as the positional argument.") |
| os.Exit(1) |
| } |
| |
| // Get the available devices. |
| fbDevices, deviceList, err := getAvailableDevices() |
| if err != nil { |
| sklog.Fatalf("Error retrieving available devices: %s", err) |
| } |
| |
| // Dump the device list and exit. |
| if *dumpDevFile != "" { |
| if err := writeDeviceList(*dumpDevFile, deviceList); err != nil { |
| sklog.Fatalf("Unable to write devices: %s", err) |
| } |
| return |
| } |
| |
| // If no devices are explicitly listed. Use all of them. |
| whiteList := deviceList |
| if *devicesFile != "" { |
| whiteList, err = readDeviceList(*devicesFile) |
| if err != nil { |
| sklog.Fatalf("Error reading device file: %s", err) |
| } |
| } |
| |
| // Make sure we can authenticate locally and in the cloud. |
| client, err := auth.NewJWTServiceAccountClient("", *serviceAccountFile, nil, gstorage.CloudPlatformScope, "https://www.googleapis.com/auth/userinfo.email") |
| if err != nil { |
| sklog.Fatalf("Failed to authenticate service account: %s. Run 'get_service_account' to obtain a service account file.", err) |
| } |
| |
| // Filter the devices according the white list and other parameters. |
| devices, ignoredDevices := filterDevices(fbDevices, whiteList, *minAPIVersion, *maxAPIVersion) |
| sklog.Infof("---\nSelected devices:") |
| logDevices(devices) |
| |
| if len(devices) == 0 { |
| sklog.Errorf("No devices selected. Not running tests.") |
| os.Exit(1) |
| } |
| |
| if err := runTests(apkPath, devices, ignoredDevices, client, *dryRun); err != nil { |
| sklog.Fatalf("Error running tests on Firebase: %s", err) |
| } |
| |
| if !*dryRun && (*uploadGCSPath != "") && (*properties != "") { |
| if err := uploadAPK(apkPath, *uploadGCSPath, *properties, client); err != nil { |
| sklog.Fatalf("Error uploading APK to '%s': %s", *uploadGCSPath, err) |
| } |
| } |
| } |
| |
| // getAvailableDevices queries Firebase Testlab for all physical devices that |
| // are not deprecated. It returns two lists with the same information. |
| // The first contains all device information as returned by Firebase while |
| // the second contains the information necessary to use in a whitelist. |
| func getAvailableDevices() ([]*DeviceVersions, DeviceList, error) { |
| // Get the list of all devices in JSON format from Firebase testlab. |
| var buf bytes.Buffer |
| var errBuf bytes.Buffer |
| cmd := parseCommand(CMD_AVAILABLE_DEVICES) |
| cmd.Stdout = &buf |
| cmd.Stderr = io.MultiWriter(os.Stdout, &errBuf) |
| if err := cmd.Run(); err != nil { |
| return nil, nil, sklog.FmtErrorf("Error running: %s\nError:%s\nStdErr:%s", CMD_AVAILABLE_DEVICES, err, errBuf) |
| } |
| |
| // Unmarshal the result. |
| foundDevices := []*DeviceVersions{} |
| bufBytes := buf.Bytes() |
| if err := json.Unmarshal(bufBytes, &foundDevices); err != nil { |
| return nil, nil, sklog.FmtErrorf("Unmarshal of device information failed: %s \nJSON Input: %s\n", err, string(bufBytes)) |
| } |
| |
| // Filter the devices and copy them to device list. |
| devList := DeviceList{} |
| ret := make([]*DeviceVersions, 0, len(foundDevices)) |
| for _, foundDev := range foundDevices { |
| // Only consider physical devices and devices that are not deprecated. |
| if (foundDev.Form == "PHYSICAL") && !util.In("deprecated", foundDev.Tags) { |
| ret = append(ret, foundDev) |
| devList = append(devList, &DevInfo{ |
| ID: foundDev.ID, |
| Name: foundDev.Name, |
| RunVersions: foundDev.VersionIDs, |
| }) |
| } |
| } |
| return foundDevices, devList, nil |
| } |
| |
| // filterDevices filters the given devices by ensuring that they are in the white list |
| // and within the given api version range. |
| // It returns two lists: (accepted_devices, ignored_devices) |
| func filterDevices(foundDevices []*DeviceVersions, whiteList DeviceList, minAPIVersion, maxAPIVersion int) ([]*DeviceVersions, []*DeviceVersions) { |
| // iterate over the available devices and partition them. |
| allDevices := make([]*DeviceVersions, 0, len(foundDevices)) |
| ret := make([]*DeviceVersions, 0, len(foundDevices)) |
| ignored := make([]*DeviceVersions, 0, len(foundDevices)) |
| for _, dev := range foundDevices { |
| // Only include devices that are on the whitelist and have versions defined. |
| if targetDev := whiteList.find(dev.ID); targetDev != nil && (len(targetDev.RunVersions) > 0) { |
| versionSet := util.NewStringSet(dev.VersionIDs) |
| reqVersions := util.NewStringSet(filterVersions(targetDev.RunVersions, minAPIVersion, maxAPIVersion)) |
| whiteListVersions := versionSet.Intersect(reqVersions).Keys() |
| ignoredVersions := versionSet.Complement(reqVersions).Keys() |
| sort.Strings(whiteListVersions) |
| sort.Strings(ignoredVersions) |
| if len(whiteListVersions) > 0 { |
| ret = append(ret, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: whiteListVersions}) |
| } |
| if len(ignoredVersions) > 0 { |
| ignored = append(ignored, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: ignoredVersions}) |
| } |
| } else { |
| ignored = append(ignored, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: dev.VersionIDs}) |
| } |
| allDevices = append(allDevices, &DeviceVersions{FirebaseDevice: dev.FirebaseDevice, RunVersions: dev.VersionIDs}) |
| } |
| |
| sklog.Infof("All devices:") |
| logDevices(allDevices) |
| |
| return ret, ignored |
| } |
| |
| // filterVersions returns the elements in versionIDs where minVersion <= element <= maxVersion. |
| func filterVersions(versionIDs []string, minVersion, maxVersion int) []string { |
| ret := make([]string, 0, len(versionIDs)) |
| for _, versionID := range versionIDs { |
| id, err := strconv.Atoi(versionID) |
| if err != nil { |
| sklog.Fatalf("Error parsing version id '%s': %s", versionID, err) |
| } |
| if (id >= minVersion) && (id <= maxVersion) { |
| ret = append(ret, versionID) |
| } |
| } |
| return ret |
| } |
| |
| // runTests runs the given apk on the given list of devices. |
| func runTests(apk_path string, devices, ignoredDevices []*DeviceVersions, client *http.Client, dryRun bool) error { |
| // Get the model-version we want to test. Assume on average each model has 5 supported versions. |
| modelSelectors := make([]string, 0, len(devices)*5) |
| for _, devRec := range devices { |
| for _, version := range devRec.RunVersions { |
| modelSelectors = append(modelSelectors, fmt.Sprintf(MODEL_VERSION_TMPL, devRec.FirebaseDevice.ID, version)) |
| } |
| } |
| |
| now := time.Now() |
| nowMs := now.UnixNano() / int64(time.Millisecond) |
| runID := fmt.Sprintf(RUN_ID_TMPL, nowMs) |
| resultsDir := fmt.Sprintf(RESULT_DIR_TMPL, now.Format("2006/01/02/15"), runID) |
| cmdStr := fmt.Sprintf(RUN_TESTS_TEMPLATE, apk_path, RESULT_BUCKET, resultsDir, strings.Join(modelSelectors, "\n")) |
| cmdStr = strings.TrimSpace(strings.Replace(cmdStr, "\n", " ", -1)) |
| |
| // Run the command. |
| var errBuf bytes.Buffer |
| cmd := parseCommand(cmdStr) |
| cmd.Stdout = os.Stdout |
| cmd.Stderr = io.MultiWriter(os.Stdout, &errBuf) |
| exitCode := 0 |
| |
| if dryRun { |
| fmt.Printf("[dry run]: Would have run this command: %s\n", cmdStr) |
| return nil |
| } |
| |
| if err := cmd.Run(); err != nil { |
| // Get the exit code. |
| if exitError, ok := err.(*exec.ExitError); ok { |
| ws := exitError.Sys().(syscall.WaitStatus) |
| exitCode = ws.ExitStatus() |
| } |
| |
| sklog.Errorf("Error running tests: %s", err) |
| sklog.Errorf("Exit code: %d", exitCode) |
| |
| // Exit code 10 means triggering on Testlab succeeded, but but some of the |
| // runs on devices failed. We consider it a success for this script. |
| if exitCode != 10 { |
| return sklog.FmtErrorf("Error running: %s\nError:%s\nStdErr:%s", cmdStr, err, errBuf) |
| } |
| } |
| |
| // Store the result in a meta json file. |
| meta := &TestRunMeta{ |
| ID: runID, |
| TS: nowMs, |
| Devices: devices, |
| IgnoredDevices: ignoredDevices, |
| ExitCode: exitCode, |
| } |
| |
| targetPath := fmt.Sprintf("%s/%s/%s", RESULT_BUCKET, resultsDir, META_DATA_FILENAME) |
| if err := meta.writeToGCS(targetPath, client); err != nil { |
| return err |
| } |
| sklog.Infof("Meta data written to gs://%s", targetPath) |
| return nil |
| } |
| |
| // uploadAPK uploads the APK at the given path to the bucket/path in gcsPath. |
| // The key-value pairs in propStr are set as custom meta data of the APK. |
| func uploadAPK(apkPath, gcsPath, propStr string, client *http.Client) error { |
| properties, err := splitProperties(propStr) |
| if err != nil { |
| return err |
| } |
| apkFile, err := os.Open(apkPath) |
| if err != nil { |
| return err |
| } |
| defer util.Close(apkFile) |
| |
| if err := copyReaderToGCS(gcsPath, apkFile, client, "application/vnd.android.package-archive", properties, true, false); err != nil { |
| return err |
| } |
| |
| sklog.Infof("APK uploaded to gs://%s", gcsPath) |
| return nil |
| } |
| |
| // splitProperties receives a comma separated list of 'key=value' pairs and |
| // returnes them as a map. |
| func splitProperties(propStr string) (map[string]string, error) { |
| splitProps := strings.Split(propStr, ",") |
| properties := make(map[string]string, len(splitProps)) |
| for _, oneProp := range splitProps { |
| kv := strings.Split(oneProp, "=") |
| if len(kv) != 2 { |
| return nil, sklog.FmtErrorf("Inavlid porperties format. Unable to parse '%s'", propStr) |
| } |
| properties[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) |
| } |
| return properties, nil |
| } |
| |
| // logDevices logs the given list of devices. |
| func logDevices(devices []*DeviceVersions) { |
| sklog.Infof("Found %d devices.", len(devices)) |
| for _, dev := range devices { |
| fbDev := dev.FirebaseDevice |
| sklog.Infof("%-15s %-30s %v / %v", fbDev.ID, fbDev.Name, fbDev.VersionIDs, dev.RunVersions) |
| } |
| } |
| |
| // parseCommad parses a command line and wraps it in an exec.Command instance. |
| func parseCommand(cmdStr string) *exec.Cmd { |
| cmdArgs := strings.Split(strings.TrimSpace(cmdStr), " ") |
| for idx := range cmdArgs { |
| cmdArgs[idx] = strings.TrimSpace(cmdArgs[idx]) |
| } |
| return exec.Command(cmdArgs[0], cmdArgs[1:]...) |
| } |
| |
| // DeviceList is a simple list of devices, primarily used to define the |
| // whitelist of devices we want to run on. |
| type DeviceList []*DevInfo |
| |
| type DevInfo struct { |
| ID string `json:"id"` |
| Name string `json:"name"` |
| RunVersions []string `json:"runVersions"` |
| } |
| |
| func (d DeviceList) find(id string) *DevInfo { |
| for _, devInfo := range d { |
| if devInfo.ID == id { |
| return devInfo |
| } |
| } |
| return nil |
| } |
| |
| func writeDeviceList(fileName string, devList DeviceList) error { |
| jsonBytes, err := json.MarshalIndent(devList, "", " ") |
| if err != nil { |
| return sklog.FmtErrorf("Unable to encode JSON: %s", err) |
| } |
| |
| if err := ioutil.WriteFile(fileName, jsonBytes, 0644); err != nil { |
| sklog.FmtErrorf("Unable to write file '%s': %s", fileName, err) |
| } |
| return nil |
| } |
| |
| func readDeviceList(fileName string) (DeviceList, error) { |
| inFile, err := os.Open(fileName) |
| if err != nil { |
| return nil, sklog.FmtErrorf("Unable to open file '%s': %s", fileName, err) |
| } |
| defer util.Close(inFile) |
| |
| var devList DeviceList |
| if err := json.NewDecoder(inFile).Decode(&devList); err != nil { |
| return nil, sklog.FmtErrorf("Unable to decode JSON from '%s': %s", fileName, err) |
| } |
| return devList, nil |
| } |
| |
| // FirebaseDevice contains the information and JSON tags for device information |
| // returned by firebase. |
| type FirebaseDevice struct { |
| Brand string `json:"brand"` |
| Form string `json:"form"` |
| ID string `json:"id"` |
| Manufacturer string `json:"manufacturer"` |
| Name string `json:"name"` |
| VersionIDs []string `json:"supportedVersionIds"` |
| Tags []string `json:"tags"` |
| } |
| |
| // DeviceVersions combines device information from Firebase Testlab with |
| // a selected list of versions. This is used to define a subset of versions |
| // used by a devices. |
| type DeviceVersions struct { |
| *FirebaseDevice |
| |
| // RunVersions contains the version ids of interest contained in Device. |
| RunVersions []string |
| } |
| |
| // TestRunMeta contains the meta data of a complete testrun on firebase. |
| type TestRunMeta struct { |
| ID string `json:"id"` |
| TS int64 `json:"timeStamp"` |
| Devices []*DeviceVersions `json:"devices"` |
| IgnoredDevices []*DeviceVersions `json:"ignoredDevices"` |
| ExitCode int `json:"exitCode"` |
| } |
| |
| // writeToGCS writes the meta data as JSON to the given bucket and path in |
| // GCS. It assumes that the provided client has permissions to write to the |
| // specified location in GCS. |
| func (t *TestRunMeta) writeToGCS(gcsPath string, client *http.Client) error { |
| jsonBytes, err := json.Marshal(t) |
| if err != nil { |
| return err |
| } |
| return copyReaderToGCS(gcsPath, bytes.NewReader(jsonBytes), client, "", nil, false, true) |
| } |
| |
| // TODO(stephana): Merge copyReaderToGCS into the go/gcs in |
| // the infra repository. |
| |
| // copyReaderToGCS reads all available content from the given reader and writes |
| // it to the given path in GCS. |
| func copyReaderToGCS(gcsPath string, reader io.Reader, client *http.Client, contentType string, metaData map[string]string, public bool, gzip bool) error { |
| storageClient, err := storage.NewClient(context.Background(), option.WithHTTPClient(client)) |
| if err != nil { |
| return err |
| } |
| bucket, path := gcs.SplitGSPath(gcsPath) |
| w := storageClient.Bucket(bucket).Object(path).NewWriter(context.Background()) |
| |
| // Set the content if requested. |
| if contentType != "" { |
| w.ObjectAttrs.ContentType = contentType |
| } |
| |
| // Set the meta data if requested |
| if metaData != nil { |
| w.Metadata = metaData |
| } |
| |
| // Make the object public if requested. |
| if public { |
| w.ACL = []storage.ACLRule{{Entity: storage.AllUsers, Role: storage.RoleReader}} |
| } |
| |
| // Write the everything the reader can provide to the GCS object. Either |
| // gzip'ed or plain. |
| if gzip { |
| w.ObjectAttrs.ContentEncoding = "gzip" |
| err = util.WithGzipWriter(w, func(w io.Writer) error { |
| _, err := io.Copy(w, reader) |
| return err |
| }) |
| } else { |
| _, err = io.Copy(w, reader) |
| } |
| |
| // Make sure we return an error when we close the remote object. |
| if err != nil { |
| _ = w.CloseWithError(err) |
| return err |
| } |
| return w.Close() |
| } |