2018-01-08 20:53:37 +00:00
/ *
* 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"
2018-03-07 19:44:44 +00:00
"context"
2018-01-08 20:53:37 +00:00
"encoding/json"
"flag"
"fmt"
2018-03-07 19:44:44 +00:00
"io"
"io/ioutil"
2018-01-08 20:53:37 +00:00
"net/http"
"os"
"os/exec"
"sort"
2018-02-02 20:05:42 +00:00
"strconv"
2018-01-08 20:53:37 +00:00
"strings"
"syscall"
"time"
2018-03-07 19:44:44 +00:00
"go.skia.org/infra/go/gcs"
"cloud.google.com/go/storage"
"google.golang.org/api/option"
2018-01-08 20:53:37 +00:00
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 (
2018-03-07 19:44:44 +00:00
devicesFile = flag . String ( "devices" , "" , "JSON file that maps device ids to versions to run on. Same format as produced by the dump_devices flag." )
2018-01-08 20:53:37 +00:00
dryRun = flag . Bool ( "dryrun" , false , "Print out the command and quit without triggering tests." )
2018-03-07 19:44:44 +00:00
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)." )
2018-01-08 20:53:37 +00:00
)
const (
RUN_TESTS_TEMPLATE = ` gcloud beta firebase test android run
-- type = game - loop
-- app = % s
-- results - bucket = % s
-- results - dir = % s
2018-01-16 20:55:48 +00:00
-- directories - to - pull = / sdcard / Android / data / org . skia . skqp
-- timeout 30 m
2018-01-08 20:53:37 +00:00
% s
`
2018-03-07 19:44:44 +00:00
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"
2018-01-08 20:53:37 +00:00
)
func main ( ) {
common . Init ( )
2018-03-07 19:44:44 +00:00
// 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 )
}
2018-01-08 20:53:37 +00:00
2018-03-07 19:44:44 +00:00
// Get the available devices.
fbDevices , deviceList , err := getAvailableDevices ( )
2018-01-08 20:53:37 +00:00
if err != nil {
2018-03-07 19:44:44 +00:00
sklog . Fatalf ( "Error retrieving available devices: %s" , err )
2018-01-08 20:53:37 +00:00
}
2018-03-07 19:44:44 +00:00
// 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" )
2018-01-08 20:53:37 +00:00
if err != nil {
2018-03-07 19:44:44 +00:00
sklog . Fatalf ( "Failed to authenticate service account: %s. Run 'get_service_account' to obtain a service account file." , err )
2018-01-08 20:53:37 +00:00
}
2018-03-07 19:44:44 +00:00
// Filter the devices according the white list and other parameters.
devices , ignoredDevices := filterDevices ( fbDevices , whiteList , * minAPIVersion , * maxAPIVersion )
sklog . Infof ( "---\nSelected devices:" )
2018-01-08 20:53:37 +00:00
logDevices ( devices )
2018-03-07 19:44:44 +00:00
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 )
}
2018-01-08 20:53:37 +00:00
}
}
2018-03-07 19:44:44 +00:00
// 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 ) {
2018-01-08 20:53:37 +00:00
// Get the list of all devices in JSON format from Firebase testlab.
var buf bytes . Buffer
2018-03-07 19:44:44 +00:00
var errBuf bytes . Buffer
cmd := parseCommand ( CMD_AVAILABLE_DEVICES )
2018-01-08 20:53:37 +00:00
cmd . Stdout = & buf
2018-03-07 19:44:44 +00:00
cmd . Stderr = io . MultiWriter ( os . Stdout , & errBuf )
2018-01-08 20:53:37 +00:00
if err := cmd . Run ( ) ; err != nil {
2018-03-07 19:44:44 +00:00
return nil , nil , sklog . FmtErrorf ( "Error running: %s\nError:%s\nStdErr:%s" , CMD_AVAILABLE_DEVICES , err , errBuf )
2018-01-08 20:53:37 +00:00
}
// Unmarshal the result.
2018-03-07 19:44:44 +00:00
foundDevices := [ ] * DeviceVersions { }
2018-01-08 20:53:37 +00:00
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 ) )
}
2018-03-07 19:44:44 +00:00
// 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 ) {
2018-01-08 20:53:37 +00:00
// iterate over the available devices and partition them.
2018-03-07 19:44:44 +00:00
allDevices := make ( [ ] * DeviceVersions , 0 , len ( foundDevices ) )
ret := make ( [ ] * DeviceVersions , 0 , len ( foundDevices ) )
ignored := make ( [ ] * DeviceVersions , 0 , len ( foundDevices ) )
2018-01-08 20:53:37 +00:00
for _ , dev := range foundDevices {
2018-03-07 19:44:44 +00:00
// 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 } )
2018-01-08 20:53:37 +00:00
}
2018-03-07 19:44:44 +00:00
} else {
ignored = append ( ignored , & DeviceVersions { FirebaseDevice : dev . FirebaseDevice , RunVersions : dev . VersionIDs } )
2018-01-08 20:53:37 +00:00
}
2018-03-07 19:44:44 +00:00
allDevices = append ( allDevices , & DeviceVersions { FirebaseDevice : dev . FirebaseDevice , RunVersions : dev . VersionIDs } )
2018-01-08 20:53:37 +00:00
}
sklog . Infof ( "All devices:" )
logDevices ( allDevices )
2018-03-07 19:44:44 +00:00
return ret , ignored
2018-01-08 20:53:37 +00:00
}
2018-02-02 20:05:42 +00:00
// 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
}
2018-01-08 20:53:37 +00:00
// runTests runs the given apk on the given list of devices.
2018-03-07 19:44:44 +00:00
func runTests ( apk_path string , devices , ignoredDevices [ ] * DeviceVersions , client * http . Client , dryRun bool ) error {
2018-01-08 20:53:37 +00:00
// 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 {
2018-03-07 19:44:44 +00:00
for _ , version := range devRec . RunVersions {
modelSelectors = append ( modelSelectors , fmt . Sprintf ( MODEL_VERSION_TMPL , devRec . FirebaseDevice . ID , version ) )
2018-01-08 20:53:37 +00:00
}
}
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.
2018-03-07 19:44:44 +00:00
var errBuf bytes . Buffer
2018-01-08 20:53:37 +00:00
cmd := parseCommand ( cmdStr )
cmd . Stdout = os . Stdout
2018-03-07 19:44:44 +00:00
cmd . Stderr = io . MultiWriter ( os . Stdout , & errBuf )
2018-01-08 20:53:37 +00:00
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 ( )
}
2018-03-07 19:44:44 +00:00
2018-01-08 20:53:37 +00:00
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 {
2018-03-07 19:44:44 +00:00
return sklog . FmtErrorf ( "Error running: %s\nError:%s\nStdErr:%s" , cmdStr , err , errBuf )
2018-01-08 20:53:37 +00:00
}
}
// Store the result in a meta json file.
2018-03-07 19:44:44 +00:00
meta := & TestRunMeta {
2018-01-08 20:53:37 +00:00
ID : runID ,
TS : nowMs ,
Devices : devices ,
IgnoredDevices : ignoredDevices ,
ExitCode : exitCode ,
}
2018-03-07 19:44:44 +00:00
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 )
2018-01-08 20:53:37 +00:00
return nil
}
2018-03-07 19:44:44 +00:00
// 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
}
2018-01-08 20:53:37 +00:00
// logDevices logs the given list of devices.
2018-03-07 19:44:44 +00:00
func logDevices ( devices [ ] * DeviceVersions ) {
2018-01-08 20:53:37 +00:00
sklog . Infof ( "Found %d devices." , len ( devices ) )
for _ , dev := range devices {
2018-03-07 19:44:44 +00:00
fbDev := dev . FirebaseDevice
sklog . Infof ( "%-15s %-30s %v / %v" , fbDev . ID , fbDev . Name , fbDev . VersionIDs , dev . RunVersions )
2018-01-08 20:53:37 +00:00
}
}
// 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 : ] ... )
}
2018-03-07 19:44:44 +00:00
// 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 ( )
}