blob: 49660c63565c5f7c4cb61b580f4bc1cdf1b7a80e [file] [log] [blame]
// Copyright 2023 Google LLC
//
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package common
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
sk_exec "go.skia.org/infra/go/exec"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/util"
"go.skia.org/infra/task_driver/go/lib/os_steps"
"go.skia.org/infra/task_driver/go/td"
)
// goldctlBazelLabelAllowList is the list of Bazel targets that are allowed to upload results to
// Gold via goldctl. This is to prevent polluting Gold with spurious digests, or digests with the
// wrong keys while we experiment with running GMs with Bazel.
//
// TODO(lovisolo): Delete once migration is complete.
var goldctlBazelLabelAllowList = map[string]bool{
"//gm:hello_bazel_world_test": true,
"//gm:hello_bazel_world_android_test": true,
}
// validLabelRegexps represent valid, fully-qualified Bazel labels.
var validLabelRegexps = []*regexp.Regexp{
regexp.MustCompile(`^//:[a-zA-Z0-9_-]+$`), // Matches "//:foo".
regexp.MustCompile(`^/(/[a-zA-Z0-9_-]+)+:[a-zA-Z0-9_-]+$`), // Matches "//foo:bar", "//foo/bar:baz", etc.
}
// ValidateLabelAndReturnOutputsZipPath validates the given Bazel label and returns the path within
// the checkout directory where the ZIP archive with undeclared test outputs will be found, if
// applicable.
func ValidateLabelAndReturnOutputsZipPath(checkoutDir, label string) (string, error) {
valid := false
for _, re := range validLabelRegexps {
if re.MatchString(label) {
valid = true
break
}
}
if !valid {
return "", skerr.Fmt("invalid label: %q", label)
}
return filepath.Join(
checkoutDir,
"bazel-testlogs",
strings.ReplaceAll(strings.TrimPrefix(label, "//"), ":", "/"),
"test.outputs",
"outputs.zip"), nil
}
// UploadToGoldArgs gathers the inputs to the UploadToGold function.
type UploadToGoldArgs struct {
BazelLabel string
GoldctlPath string
GitCommit string
ChangelistID string
PatchsetOrder string // 1, 2, 3, etc.
TryjobID string
// TestOnlyAllowAnyBazelLabel should only be used from tests. If true, the
// goldctlBazelLabelAllowList will be ignored.
//
// TODO(lovisolo): Delete once migration is complete.
TestOnlyAllowAnyBazelLabel bool
}
// UploadToGold uploads any GM results to Gold via goldctl.
func UploadToGold(ctx context.Context, utgArgs UploadToGoldArgs, outputsZIPOrDir string) error {
// TODO(lovisolo): Delete once migration is complete.
if !utgArgs.TestOnlyAllowAnyBazelLabel {
if _, ok := goldctlBazelLabelAllowList[utgArgs.BazelLabel]; !ok {
return skerr.Wrap(td.Do(ctx, td.Props(fmt.Sprintf("Bazel label %q is not allowlisted to upload to Gold; skipping goldctl steps", utgArgs.BazelLabel)), func(ctx context.Context) error {
return nil
}))
}
}
// Were there any undeclared test outputs?
fileInfo, err := os.Stat(outputsZIPOrDir)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return td.Do(ctx, td.Props("Test did not produce an undeclared test outputs ZIP file or directory; nothing to upload to Gold"), func(ctx context.Context) error {
return nil
})
} else {
return skerr.Wrap(err)
}
}
// If the undeclared outputs ZIP file or directory is a ZIP file, extract it.
outputsDir := ""
if fileInfo.IsDir() {
outputsDir = outputsZIPOrDir
} else {
var err error
outputsDir, err = extractOutputsZip(ctx, outputsZIPOrDir)
if err != nil {
return skerr.Wrap(err)
}
defer util.RemoveAll(outputsDir)
}
// Gather GM outputs.
gmOutputs, err := gatherGMOutputs(ctx, outputsDir)
if err != nil {
return skerr.Wrap(err)
}
if len(gmOutputs) == 0 {
return td.Do(ctx, td.Props("Undeclared test outputs ZIP file or directory contains no GM outputs; nothing to upload to Gold"), func(ctx context.Context) error {
return nil
})
}
return td.Do(ctx, td.Props("Upload GM outputs to Gold"), func(ctx context.Context) error {
// Create working directory for goldctl.
goldctlWorkDir, err := os_steps.TempDir(ctx, "", "goldctl-workdir-*")
if err != nil {
return skerr.Wrap(err)
}
defer util.RemoveAll(goldctlWorkDir)
// Authorize goldctl.
if err := goldctl(ctx, utgArgs.GoldctlPath, "auth", "--work-dir", goldctlWorkDir, "--luci"); err != nil {
return skerr.Wrap(err)
}
// Prepare task-specific key:value pairs.
var taskSpecificKeyValuePairs []string
for k, v := range computeTaskSpecificGoldctlKeyValuePairs() {
taskSpecificKeyValuePairs = append(taskSpecificKeyValuePairs, k+":"+v)
}
sort.Strings(taskSpecificKeyValuePairs) // Sort for determinism.
// Initialize goldctl.
args := []string{
"imgtest", "init",
"--work-dir", goldctlWorkDir,
"--instance", "skia",
// If we use flag --instance alone, goldctl will incorrectly infer the Gold instance URL as
// https://skia-gold.skia.org.
"--url", "https://gold.skia.org",
// Similarly, unless we specify a GCE bucket explicitly, goldctl will incorrectly infer
// "skia-gold-skia" as the instance's bucket.
"--bucket", "skia-infra-gm",
"--git_hash", utgArgs.GitCommit,
}
if utgArgs.ChangelistID != "" && utgArgs.PatchsetOrder != "" {
args = append(args,
"--crs", "gerrit",
"--cis", "buildbucket",
"--changelist", utgArgs.ChangelistID,
"--patchset", utgArgs.PatchsetOrder,
"--jobid", utgArgs.TryjobID)
}
for _, kv := range taskSpecificKeyValuePairs {
args = append(args, "--key", kv)
}
if err := goldctl(ctx, utgArgs.GoldctlPath, args...); err != nil {
return skerr.Wrap(err)
}
// Add PNGs.
for _, gmOutput := range gmOutputs {
args := []string{
"imgtest", "add",
"--work-dir", goldctlWorkDir,
"--test-name", gmOutput.TestName,
"--png-file", gmOutput.PNGPath,
"--png-digest", gmOutput.MD5,
}
var testSpecificKeyValuePairs []string
for k, v := range gmOutput.Keys {
testSpecificKeyValuePairs = append(testSpecificKeyValuePairs, k+":"+v)
}
sort.Strings(testSpecificKeyValuePairs) // Sort for determinism.
for _, kv := range testSpecificKeyValuePairs {
// We assume that all keys are non-optional. That is, all keys are part of the trace. It is
// possible to add support for optional keys in the future, which can be specified via the
// --add-test-optional-key flag.
args = append(args, "--add-test-key", kv)
}
if err := goldctl(ctx, utgArgs.GoldctlPath, args...); err != nil {
return skerr.Wrap(err)
}
}
// Finalize and upload screenshots to Gold.
return goldctl(ctx, utgArgs.GoldctlPath, "imgtest", "finalize", "--work-dir", goldctlWorkDir)
})
}
// extractOutputsZip extracts the undeclared outputs ZIP archive into a temporary directory, and
// returns the path to said directory.
func extractOutputsZip(ctx context.Context, outputsZipPath string) (string, error) {
// Create extraction directory.
extractionDir, err := os_steps.TempDir(ctx, "", "bazel-test-output-dir-*")
if err != nil {
return "", skerr.Wrap(err)
}
// Extract ZIP archive.
if err := td.Do(ctx, td.Props(fmt.Sprintf("Extract undeclared outputs archive %s into %s", outputsZipPath, extractionDir)), func(ctx context.Context) error {
outputsZip, err := zip.OpenReader(outputsZipPath)
if err != nil {
return skerr.Wrap(err)
}
defer util.Close(outputsZip)
for _, file := range outputsZip.File {
// Skip directories. We assume all output files are at the root directory of the archive.
if file.FileInfo().IsDir() {
if err := td.Do(ctx, td.Props(fmt.Sprintf("Not extracting subdirectory: %s", file.Name)), func(ctx context.Context) error { return nil }); err != nil {
return skerr.Wrap(err)
}
continue
}
// Ignore anything that is not a PNG or JSON file.
if !strings.HasSuffix(strings.ToLower(file.Name), ".png") && !strings.HasSuffix(strings.ToLower(file.Name), ".json") {
if err := td.Do(ctx, td.Props(fmt.Sprintf("Not extracting non-PNG / non-JSON file: %s", file.Name)), func(ctx context.Context) error { return nil }); err != nil {
return skerr.Wrap(err)
}
continue
}
// Extract file.
if err := td.Do(ctx, td.Props(fmt.Sprintf("Extracting file: %s", file.Name)), func(ctx context.Context) error {
reader, err := file.Open()
if err != nil {
return skerr.Wrap(err)
}
defer util.Close(reader)
buf := &bytes.Buffer{}
if _, err := io.Copy(buf, reader); err != nil {
return skerr.Wrap(err)
}
return skerr.Wrap(os.WriteFile(filepath.Join(extractionDir, file.Name), buf.Bytes(), 0644))
}); err != nil {
return skerr.Wrap(err)
}
}
return nil
}); err != nil {
return "", skerr.Wrap(err)
}
return extractionDir, nil
}
// gmJSONOutput represents a JSON file produced by //gm/BazelGMRunner.cpp, plus bookkeeping
// information required by this task driver.
type gmJSONOutput struct {
MD5 string `json:"md5"`
Keys map[string]string `json:"keys"`
TestName string `json:"-"` // Convenience alias, should be the same as the "name" key.
PNGPath string `json:"-"`
}
// gatherGMOutputs inspects a directory with the contents of the undeclared test outputs ZIP
// archive and gathers any GM outputs found therein.
func gatherGMOutputs(ctx context.Context, outputsDir string) ([]gmJSONOutput, error) {
var outputs []gmJSONOutput
if err := td.Do(ctx, td.Props("Gather JSON and PNG files produced by GMs"), func(ctx context.Context) error {
files, err := os.ReadDir(outputsDir)
if err != nil {
return skerr.Wrap(err)
}
for _, file := range files {
if !strings.HasSuffix(file.Name(), ".json") {
continue
}
jsonPath := file.Name()
pngPath := strings.TrimSuffix(jsonPath, ".json") + ".png"
testName := strings.TrimSuffix(jsonPath, ".json")
// Skip JSON file if there is no associated PNG file.
if _, err := os.Stat(filepath.Join(outputsDir, pngPath)); err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := td.Do(ctx, td.Props(fmt.Sprintf("Ignoring %q: file %q not found", jsonPath, pngPath)), func(ctx context.Context) error {
return nil
}); err != nil {
return skerr.Wrap(err)
}
continue
} else {
return skerr.Wrap(err)
}
}
// Parse JSON file. Skip it if parsing fails (rather than failing the entire task in the off
// chance that the test has other kinds of undeclared outputs).
bytes, err := os.ReadFile(filepath.Join(outputsDir, jsonPath))
if err != nil {
return skerr.Wrap(err)
}
output := gmJSONOutput{
TestName: testName,
PNGPath: filepath.Join(outputsDir, pngPath),
}
if err := json.Unmarshal(bytes, &output); err != nil {
if err := td.Do(ctx, td.Props(fmt.Sprintf("Ignoring %q; JSON parsing error: %s", jsonPath, err)), func(ctx context.Context) error {
return nil
}); err != nil {
return skerr.Wrap(err)
}
continue
}
if output.MD5 == "" {
if err := td.Do(ctx, td.Props(fmt.Sprintf(`Ignoring %q: field "md5" not found`, jsonPath)), func(ctx context.Context) error {
return nil
}); err != nil {
return skerr.Wrap(err)
}
continue
}
// Save GM output.
if err := td.Do(ctx, td.Props(fmt.Sprintf("Gather %q", pngPath)), func(ctx context.Context) error {
outputs = append(outputs, output)
return nil
}); err != nil {
return skerr.Wrap(err)
}
}
return nil
}); err != nil {
return nil, skerr.Wrap(err)
}
// Sort outputs for determinism.
sort.Slice(outputs, func(i, j int) bool {
return outputs[i].TestName < outputs[j].TestName
})
return outputs, nil
}
// goldctl runs the goldctl command.
func goldctl(ctx context.Context, goldctlPath string, args ...string) error {
cmd := &sk_exec.Command{
Name: goldctlPath,
Args: args,
LogStdout: true,
LogStderr: true,
}
_, err := sk_exec.RunCommand(ctx, cmd)
return skerr.Wrap(err)
}
// computeTaskSpecificGoldctlKeyValuePairs returns the set of task-specific key-value pairs.
//
// TODO(lovisolo): Infer these key-value pairs from the Bazel config, host, etc.
func computeTaskSpecificGoldctlKeyValuePairs() map[string]string {
// The "os" key produced by DM can have values like these:
//
// - Android
// - ChromeOS
// - Debian10
// - Mac10.15.7
// - Mac11
// - Ubuntu18
// - Win10
// - Win2019
// - iOS
//
// TODO(lovisolo): Determine the "os" key in a fashion similar to DM.
if runtime.GOOS != "linux" {
panic("only linux is supported at this time")
}
os := "linux"
// TODO(lovisolo): Delete this temporary hack.
if runtime.GOARCH == "arm" || runtime.GOARCH == "arm64" {
// As a temporary hack to be able to generate diferent traces for the same GM on Linux vs.
// Android, we assume that if the task driver is running on an ARM machine, then it's a
// Raspberry Pi connected to an Android phone. This is only for use while we experiment with
// Bazel-built GMs.
//
// Moving forward, we should try to derive the "os", "model" and "arch" keys from the
// BazelTest-* task's "host" component. A potential approach could be to use hosts such as
// "NUC9i7QN_Debian11". In this example, we can derive the "model" and "arch" keys from the
// "NUC9i7QN" part, and the "os" key would match the "Debian11" part.
os = "android"
}
// TODO(lovisolo): "arch" key ("arm", "arm64", "x86", "x86_64", etc.).
// TODO(lovisolo): "configuration" key ("Debug", "Release", "OptimizeForSize", etc.).
// TODO(lovisolo): "model" key ("MacBook10.1", "Pixel5", "iPadPro", "iPhone11", etc.).
return map[string]string{
"os": os,
}
}