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