• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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