// 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) }