blob: b51775163a1379408c8287b9b746882625e89b3a [file] [log] [blame]
// Application to wrap Python scripts downloaded from Gerrit.
//
// The Python scripts run benchmarks and emit Perf compatible JSON files, which
// are then uploaded to Google Cloud Storage.
//
// Every Python script must support the following flags:
//
// -b BROWSER, --browser=BROWSER
// The browser to use to run MotionMark in.
// -s SUITE, --suite=SUITE
// Run only the specified suite of tests.
// -e EXECUTABLE, --executable-path=EXECUTABLE
// Path to the executable to the driver binary.
// -a ARGUMENTS, --arguments=ARGUMENTS
// Extra arguments to pass to the browser.
// -g GITHASH, --githash=GITHASH
// A git-hash associated with this run.
// -o OUTPUT, --output=OUTPUT
// Path to the output json file.
// --extra-keys=KEY_VALUE_PAIRS
// A comma separated list of key,value pairs.
//
package compui
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"path/filepath"
"runtime"
"strings"
"time"
"cloud.google.com/go/storage"
"go.skia.org/infra/comp-ui/go/compui/download"
"go.skia.org/infra/comp-ui/go/compui/urls"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/common"
"go.skia.org/infra/go/executil"
"go.skia.org/infra/go/gcs"
"go.skia.org/infra/go/gcs/gcsclient"
"go.skia.org/infra/go/git"
"go.skia.org/infra/go/gitauth"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/now"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/timer"
"go.skia.org/infra/go/util"
"golang.org/x/oauth2"
"google.golang.org/api/option"
)
const (
// The Google Cloud Storage Bucket to write the results to.
bucket = "chrome-comp-ui-perf-skia"
// The path in the bucket where Perf results should be written.
bucketPath = "ingest"
// The repo that has commits associated with runs of the cron job.
repo = "https://skia.googlesource.com/perf-compui"
benchmarkTimeout = 2 * time.Hour
)
// DriverType is the type of executable to pass to the script via the
// --executable-path flag.
type DriverType string
const (
ChromeStableDriver DriverType = "stable"
ChromeCanaryDriver DriverType = "canary"
NoDriver DriverType = ""
)
// Benchmark represents a single Benchmark configuration.
type Benchmark struct {
// The checkout URL of the git repo that contains the scripts to run
RepoURL string `json:"repoURL"`
// The directories in the git repo that need to be checked out.
CheckoutPaths []string `json:"checkoutPaths"`
// The full name of the script to run in the git repo relative to the root
// of the checkout.
ScriptName string `json:"scriptName"`
// DriverType is the type of driver Selenium needs.
DriverType DriverType `json:"driverType"`
// Flags to pass to the Python script.
Flags []string `json:"flags"`
}
var defaultBenchmarks = map[string]*Benchmark{
// We always run the canary to validate that the whole pipeline works even
// if the "real" benchmark scripts start to fail.
"canary": {
RepoURL: "https://skia.googlesource.com/buildbot",
CheckoutPaths: []string{"comp-ui"},
ScriptName: "comp-ui/benchmark-mock.py",
DriverType: NoDriver,
Flags: []string{
"--browser", "mock",
},
},
// Chrome Stable
"chrome-motionmark": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/motionmark.py",
DriverType: ChromeStableDriver,
Flags: []string{
"--browser", "chrome",
"--extra-keys", "channel,stable,browser_type,chrome",
},
},
"chrome-jetstream": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/jetstream.py",
DriverType: ChromeStableDriver,
Flags: []string{
"--browser", "chrome",
"--extra-keys", "channel,stable,browser_type,chrome",
},
},
"chrome-speedometer": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/speedometer.py",
DriverType: ChromeStableDriver,
Flags: []string{
"--browser", "chrome",
"--extra-keys", "channel,stable,browser_type,chrome",
},
},
// Chrome Canary
"chrome-canary-motionmark": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/motionmark.py",
DriverType: ChromeCanaryDriver,
Flags: []string{
"--browser", "chrome",
"--chrome-path", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"--extra-keys", "channel,canary,browser_type,chrome",
},
},
"chrome-canary-jetstream": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/jetstream.py",
DriverType: ChromeCanaryDriver,
Flags: []string{
"--browser", "chrome",
"--chrome-path", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"--extra-keys", "channel,canary,browser_type,chrome",
},
},
"chrome-canary-speedometer": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/speedometer.py",
DriverType: ChromeCanaryDriver,
Flags: []string{
"--browser", "chrome",
"--chrome-path", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
"--extra-keys", "channel,canary,browser_type,chrome",
},
},
// Safari Stable
"safari-speedometer": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/speedometer.py",
DriverType: NoDriver,
Flags: []string{
"--browser", "safari",
"--extra-keys", "channel,stable,browser_type,safari",
},
},
"safari-motionmark": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/motionmark.py",
DriverType: NoDriver,
Flags: []string{
"--browser", "safari",
"--extra-keys", "channel,stable,browser_type,safari",
},
},
"safari-jetstream": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/jetstream.py",
DriverType: NoDriver,
Flags: []string{
"--browser", "safari",
"--extra-keys", "channel,stable,browser_type,safari",
},
},
// Safari Tech Preview
"safari-tp-speedometer": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/speedometer.py",
DriverType: NoDriver,
Flags: []string{
"--browser", "stp",
"--extra-keys", "channel,canary,browser_type,safari",
},
},
"safari-tp-motionmark": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/motionmark.py",
DriverType: NoDriver,
Flags: []string{
"--browser", "stp",
"--extra-keys", "channel,canary,browser_type,safari",
},
},
"safari-tp-jetstream": {
RepoURL: "https://chromium.googlesource.com/chromium/src",
CheckoutPaths: []string{"tools/browserbench-webdriver"},
ScriptName: "tools/browserbench-webdriver/jetstream.py",
DriverType: NoDriver,
Flags: []string{
"--browser", "stp",
"--extra-keys", "channel,canary,browser_type,safari",
},
},
}
// Application used to control how Run() executes.
type Application struct {
local bool
useDefaultAuth bool
python string
benchmarksFile string
downloadDrivers bool
chromeDriverFilename string
chromeCanaryDriverFilename string
skipUploads bool
}
// Flagset constructs a flag.FlagSet for the Application.
func (f *Application) Flagset() *flag.FlagSet {
flagSet := flag.NewFlagSet("", flag.ExitOnError)
flagSet.Usage = func() {
fmt.Printf("Usage: %s <flags>\n\n", os.Args[0])
flagSet.PrintDefaults()
}
flagSet.BoolVar(&f.local, "local", false, "Running locally if true. As opposed to in production.")
flagSet.BoolVar(&f.useDefaultAuth, "use-default-auth", false, "Use Google Default Application Credentials if true, otherwise use embedded auth Key.")
flagSet.StringVar(&f.python, "python-exe", "/Library/Frameworks/Python.framework/Versions/3.9/bin/python3", "Absolute path to the Python exe to use.")
flagSet.StringVar(&f.benchmarksFile, "benchmarks", "", "If provided, read the configs to test from the provided JSON file.")
flagSet.BoolVar(&f.downloadDrivers, "download_drivers", true, "If true then download the Chrome drivers.")
flagSet.StringVar(&f.chromeDriverFilename, "chrome_driver", "", "If --download_drivers is false then this flag must provide the absolute path to an already downloaded Chrome Selenium driver executable.")
flagSet.StringVar(&f.chromeCanaryDriverFilename, "chrome_canary_driver", "", "If --download_drivers is false then this flag must provide the absolute path to an already downloaded Chrome Canary Selenium driver executable.")
flagSet.BoolVar(&f.skipUploads, "skip_uploads", false, "If true then skip uploading the files.")
return flagSet
}
// Main runs the application.
func Main(version, key string) {
var app Application
common.InitWithMust(
"comp-ui-cron-job",
common.CloudLogging(&app.local, "skia-public"),
common.FlagSetOpt(app.Flagset()),
)
app.Run(version, key)
}
// Run the application with the given flags.
func (app Application) Run(version, key string) {
sklog.Infof("Version: %s", version)
ctx := context.Background()
benchmarks := defaultBenchmarks
if app.benchmarksFile != "" {
var err error
benchmarks, err = readBenchMarksFromFile(ctx, app.benchmarksFile)
if err != nil {
sklog.Fatal(err)
}
}
chromeDriver, chromeCanaryDriver, cleanup, err := driverFilenames(app.downloadDrivers, app.chromeDriverFilename, app.chromeCanaryDriverFilename)
if err != nil {
sklog.Fatal(err)
}
populateBenchmarksWithDrivers(benchmarks, chromeDriver, chromeCanaryDriver)
ts, err := auth.NewTokenSourceFromKeyString(ctx, app.useDefaultAuth, key, storage.ScopeFullControl, auth.ScopeUserinfoEmail, auth.ScopeGerrit)
if err != nil {
sklog.Fatal(err)
}
gcsClient, err := getGCSClient(ctx, ts)
if err != nil {
sklog.Fatal(err)
}
workDir, err := os.MkdirTemp("", "comp-ui-cron-job")
if err != nil {
sklog.Fatal(err)
}
defer func() {
err := os.RemoveAll(workDir)
if err != nil {
sklog.Error(err)
}
if err := cleanup(); err != nil {
sklog.Fatal(err)
}
}()
// We presume that if running locally that you've already authenticated to
// Gerrit, otherwise write out a git cookie that enables R/W access to the
// git repo.
//
// Authenticate to Gerrit since the perf-compui repo is private.
if !app.useDefaultAuth {
sklog.Info("Configuring git auth.")
if _, err := gitauth.New(ts, "/tmp/git-cookie", true, ""); err != nil {
sklog.Fatal(err)
}
}
sklog.Info("Getting githash.")
gitHash, err := getGitHash(ctx, workDir)
if err != nil {
sklog.Fatal(err)
}
for benchmarkName, config := range benchmarks {
outputFilename, err := runSingleBenchmark(ctx, app.python, benchmarkName, config, gitHash, workDir)
if err != nil {
sklog.Errorf("Failed to run benchmark %q: %s", benchmarkName, err)
continue
}
if app.skipUploads {
continue
}
err = uploadResultsFile(ctx, gcsClient, benchmarkName, outputFilename)
if err != nil {
sklog.Errorf("Failed to upload benchmark results %q: %s", benchmarkName, err)
}
}
sklog.Flush()
}
func driverFilenames(downloadDrivers bool, chromeDriverFilename, chromeCanaryDriverFilename string) (string, string, download.Cleanup, error) {
if !downloadDrivers {
if chromeDriverFilename == "" {
return "", "", nil, fmt.Errorf("Since --download_drivers=false, the --chrome_driver must be supplied.")
}
if chromeCanaryDriverFilename == "" {
return "", "", nil, fmt.Errorf("Since --download_drivers=false, the --chrome_canary_driver must be supplied.")
}
return chromeDriverFilename, chromeCanaryDriverFilename, download.NoopCleanup, nil
}
/*
We need to download and use the latest vesion of the Selenium Chrome driver for both
Stable and Canary channels.
For the Stable driver the version to use is available at:
https://chromedriver.storage.googleapis.com/LATEST_RELEASE
$ curl https://chromedriver.storage.googleapis.com/LATEST_RELEASE
102.0.5005.61
That version value can then be used to construct the URL of file to download:
$ curl https://chromedriver.storage.googleapis.com/102.0.5005.61/chromedriver_mac64_m1.zip --output /tmp/chr.zip
$ unzip chr.zip
Archive: chr.zip
inflating: chromedriver
The same goes for the canary driver, but note that the os/arch is also part of the URL:
https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac_Arm/LAST_CHANGE
$ curl https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac_Arm/LAST_CHANGE
1010187
Again we can use the version value and the os/arch to build the download URL:
$ curl https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac_Arm/1010187/chromedriver_mac64.zip --output /tmp/driver.zip
$ unzip /tmp/driver.zip
Archive: driver.zip
inflating: chromedriver_mac64/chromedriver
The list of os/arch filenames we care about are:
chromedriver_linux64.zip
chromedriver_mac64.zip
chromedriver_mac64_m1.zip
There's also a Win version, but we ignore that for now.
*/
urls, err := urls.NewDownloadURLs(runtime.GOOS, runtime.GOARCH)
if err != nil {
return "", "", nil, err
}
client := httputils.DefaultClientConfig().Client()
driverFilename, driverCleanup, err := download.DownloadAndUnzipDriver(client, urls.LatestURL, urls.DriverURL)
if err != nil {
return "", "", nil, err
}
canaryDriverFilename, canaryDriverCleanup, err := download.DownloadAndUnzipDriver(client, urls.LatestCanaryURL, urls.CanaryDriverURL)
if err != nil {
if err := driverCleanup(); err != nil {
sklog.Error(err)
}
return "", "", nil, err
}
// Build a single cleanup from the two cleanup functions we got from both
// downloadAndUnzip calls.
cleanup := func() error {
if err := driverCleanup(); err != nil {
return err
}
if err := canaryDriverCleanup(); err != nil {
return err
}
return nil
}
return driverFilename, canaryDriverFilename, cleanup, nil
}
func populateBenchmarksWithDrivers(in map[string]*Benchmark, chromeDriverFilename, chromeCanaryDriverFilename string) {
for _, value := range in {
switch value.DriverType {
case ChromeStableDriver:
value.Flags = append(value.Flags, "--executable-path", chromeDriverFilename)
case ChromeCanaryDriver:
value.Flags = append(value.Flags, "--executable-path", chromeCanaryDriverFilename)
}
}
}
func readBenchMarksFromFile(ctx context.Context, filename string) (map[string]*Benchmark, error) {
sklog.Infof("Reading configs from %q", filename)
benchmarks := map[string]*Benchmark{}
b, err := ioutil.ReadFile(filename)
if err != nil {
return nil, skerr.Wrapf(err, "Failed to read file.")
}
if err := json.Unmarshal(b, &benchmarks); err != nil {
return nil, skerr.Wrapf(err, "Failed to parse JSON.")
}
return benchmarks, nil
}
// getGitHash returns the git hash of the last commit to the perf-compui repo,
// which only gets a single commit per day.
func getGitHash(ctx context.Context, workDir string) (string, error) {
// Find the githash for 'today' from https://skia.googlesource.com/perf-compui.
g, err := git.NewRepo(ctx, repo, filepath.Join(workDir, "perf-compui"))
if err != nil {
return "", skerr.Wrap(err)
}
hashes, err := g.RevList(ctx, "HEAD", "-n1")
if err != nil {
return "", skerr.Wrap(err)
}
return hashes[0], nil
}
func getGCSClient(ctx context.Context, ts oauth2.TokenSource) (*gcsclient.StorageClient, error) {
storageClient, err := storage.NewClient(ctx, option.WithTokenSource(ts))
if err != nil {
return nil, skerr.Wrap(err)
}
gcsClient := gcsclient.New(storageClient, bucket)
return gcsClient, nil
}
func runSingleBenchmark(ctx context.Context, python string, benchmarkName string, config *Benchmark, gitHash string, workDir string) (string, error) {
defer timer.New(fmt.Sprintf("runSingleBenchmark - %s", benchmarkName)).Stop()
sklog.Infof("runSingleBenchMark - benchmarkName: %q url: %q gitHash: %q workDir: %q", benchmarkName, config.ScriptName, gitHash, workDir)
gitCheckoutDir, err := checkoutPythonScript(ctx, config, workDir, benchmarkName)
if err != nil {
return "", skerr.Wrap(err)
}
// Compute the filenames we will use.
scriptFilename := filepath.Join(gitCheckoutDir, config.ScriptName)
outputDirectory := filepath.Join(workDir, benchmarkName)
outputFilename := filepath.Join(outputDirectory, "results.json")
// Create output directory.
err = os.MkdirAll(outputDirectory, 0755)
if err != nil {
return "", skerr.Wrap(err)
}
// Compute the full set of args to pass to the python script.
flags := append([]string{}, config.Flags...)
flags = append(flags, "--githash", gitHash)
flags = append(flags, "--output", outputFilename)
args := append([]string{scriptFilename}, flags...)
sklog.Infof("Running: %q", args)
// Run the script.
err = runBenchMarkScript(ctx, python, args, workDir)
if err != nil {
return "", skerr.Wrap(err)
}
return outputFilename, nil
}
func runBenchMarkScript(ctx context.Context, python string, args []string, workDir string) error {
ctx, cancel := context.WithTimeout(ctx, benchmarkTimeout)
defer cancel()
return runCmdLogOutput(ctx, workDir, python, args...)
}
func uploadResultsFile(ctx context.Context, gcsClient gcs.GCSClient, benchmarkName string, outputFilename string) error {
// GCS paths always use "/" separators.
destinationPath := path.Join(bucketPath, computeUploadPathFromTime(ctx), benchmarkName, "results.json")
sklog.Infof("Upload to %q", destinationPath)
w := gcsClient.FileWriter(ctx, destinationPath, gcs.FileWriteOptions{
ContentEncoding: "application/json",
})
// Copy the output file up to GCS.
err := util.WithReadFile(outputFilename, func(f io.Reader) error {
_, err := io.Copy(w, f)
return skerr.Wrap(err)
})
if err != nil {
return skerr.Wrap(err)
}
return skerr.Wrap(w.Close())
}
// computeUploadPathFromTime returns the date-time portion of the GCS path that
// Perf expects uploads to use, see
// https://skia.googlesource.com/buildbot/+/refs/heads/main/perf/FORMAT.md.
func computeUploadPathFromTime(ctx context.Context) string {
return now.Now(ctx).UTC().Format("2006/01/02/15")
}
// checkoutPythonScript checks out a sparse checkout of the specified directories
// into workDir.
func checkoutPythonScript(ctx context.Context, config *Benchmark, workDir string, benchmarkName string) (string, error) {
dest := filepath.Join(workDir, "git", benchmarkName)
return dest, newSparseCheckout(ctx, workDir, config.RepoURL, dest, config.CheckoutPaths)
}
// runCmdLogOutput runs a command using executil.CommandContext and logs any output
// to sklog.Info().
func runCmdLogOutput(ctx context.Context, cwd string, cmd string, args ...string) error {
cc := executil.CommandContext(ctx, cmd, args...)
cc.Dir = cwd
output, err := cc.CombinedOutput()
lines := strings.Split(string(output), "\n")
for _, line := range lines {
sklog.Info(line)
}
if err != nil {
return skerr.Wrap(err)
}
return nil
}
// newSparseCheckout does a sparse checkout of the given 'directories'.
func newSparseCheckout(ctx context.Context, workDir, repoURL, dest string, directories []string) error {
if err := runCmdLogOutput(ctx, workDir, "git", "clone", "--depth", "1", "--filter=blob:none", "--sparse", repoURL, dest); err != nil {
return skerr.Wrapf(err, "Failed to clone.")
}
if err := runCmdLogOutput(ctx, dest, "git", "sparse-checkout", "init", "--cone"); err != nil {
return skerr.Wrapf(err, "Failed to init sparse checkout.")
}
args := []string{"sparse-checkout", "set"}
args = append(args, directories...)
if err := runCmdLogOutput(ctx, dest, "git", args...); err != nil {
return skerr.Wrapf(err, "Failed to do a sparse checkout.")
}
return nil
}