blob: d77b0d7dec0a0e8ad00633a3712c88cb4a5e644e [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 (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
sk_exec "go.skia.org/infra/go/exec"
"go.skia.org/skia/bazel/device_specific_configs"
"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,
}
// UploadToGoldArgs gathers the inputs to the UploadToGold function.
type UploadToGoldArgs struct {
BazelLabel string
DeviceSpecificBazelConfig 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.
if utgArgs.DeviceSpecificBazelConfig == "" {
return skerr.Fmt("DeviceSpecificBazelConfig cannot be empty")
}
deviceSpecificBazelConfig, ok := device_specific_configs.Configs[utgArgs.DeviceSpecificBazelConfig]
if !ok {
return skerr.Fmt("unknown DeviceSpecificBazelConfig: %q", utgArgs.DeviceSpecificBazelConfig)
}
var taskSpecificKeyValuePairs []string
for k, v := range deviceSpecificBazelConfig.Keys {
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)
})
}
// gmJSONOutput represents a JSON file produced by //tools/testrunners/gm/BazelGMTestRunner.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)
}