xref: /aosp_15_r20/external/skia/infra/bots/task_drivers/common/goldctl_steps.go (revision c8dee2aa9b3f27cf6c858bd81872bdeb2c07ed17)
1// Copyright 2023 Google LLC
2//
3// Use of this source code is governed by a BSD-style license that can be
4// found in the LICENSE file.
5
6package common
7
8import (
9	"context"
10	"encoding/json"
11	"errors"
12	"fmt"
13	"os"
14	"path/filepath"
15	"sort"
16	"strings"
17
18	sk_exec "go.skia.org/infra/go/exec"
19	"go.skia.org/skia/bazel/device_specific_configs"
20
21	"go.skia.org/infra/go/skerr"
22	"go.skia.org/infra/go/util"
23	"go.skia.org/infra/task_driver/go/lib/os_steps"
24	"go.skia.org/infra/task_driver/go/td"
25)
26
27// goldctlBazelLabelAllowList is the list of Bazel targets that are allowed to upload results to
28// Gold via goldctl. This is to prevent polluting Gold with spurious digests, or digests with the
29// wrong keys while we experiment with running GMs with Bazel.
30//
31// TODO(lovisolo): Delete once migration is complete.
32var goldctlBazelLabelAllowList = map[string]bool{
33	"//gm:hello_bazel_world_test":         true,
34	"//gm:hello_bazel_world_android_test": true,
35}
36
37// UploadToGoldArgs gathers the inputs to the UploadToGold function.
38type UploadToGoldArgs struct {
39	BazelLabel                string
40	DeviceSpecificBazelConfig string
41	GoldctlPath               string
42	GitCommit                 string
43	ChangelistID              string
44	PatchsetOrder             string // 1, 2, 3, etc.
45	TryjobID                  string
46
47	// TestOnlyAllowAnyBazelLabel should only be used from tests. If true, the
48	// goldctlBazelLabelAllowList will be ignored.
49	//
50	// TODO(lovisolo): Delete once migration is complete.
51	TestOnlyAllowAnyBazelLabel bool
52}
53
54// UploadToGold uploads any GM results to Gold via goldctl.
55func UploadToGold(ctx context.Context, utgArgs UploadToGoldArgs, outputsZIPOrDir string) error {
56	// TODO(lovisolo): Delete once migration is complete.
57	if !utgArgs.TestOnlyAllowAnyBazelLabel {
58		if _, ok := goldctlBazelLabelAllowList[utgArgs.BazelLabel]; !ok {
59			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 {
60				return nil
61			}))
62		}
63	}
64
65	// Were there any undeclared test outputs?
66	fileInfo, err := os.Stat(outputsZIPOrDir)
67	if err != nil {
68		if errors.Is(err, os.ErrNotExist) {
69			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 {
70				return nil
71			})
72		} else {
73			return skerr.Wrap(err)
74		}
75	}
76
77	// If the undeclared outputs ZIP file or directory is a ZIP file, extract it.
78	outputsDir := ""
79	if fileInfo.IsDir() {
80		outputsDir = outputsZIPOrDir
81	} else {
82		var err error
83		outputsDir, err = ExtractOutputsZip(ctx, outputsZIPOrDir)
84		if err != nil {
85			return skerr.Wrap(err)
86		}
87		defer util.RemoveAll(outputsDir)
88	}
89
90	// Gather GM outputs.
91	gmOutputs, err := gatherGMOutputs(ctx, outputsDir)
92	if err != nil {
93		return skerr.Wrap(err)
94	}
95	if len(gmOutputs) == 0 {
96		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 {
97			return nil
98		})
99	}
100
101	return td.Do(ctx, td.Props("Upload GM outputs to Gold"), func(ctx context.Context) error {
102		// Create working directory for goldctl.
103		goldctlWorkDir, err := os_steps.TempDir(ctx, "", "goldctl-workdir-*")
104		if err != nil {
105			return skerr.Wrap(err)
106		}
107		defer util.RemoveAll(goldctlWorkDir)
108
109		// Authorize goldctl.
110		if err := goldctl(ctx, utgArgs.GoldctlPath, "auth", "--work-dir", goldctlWorkDir, "--luci"); err != nil {
111			return skerr.Wrap(err)
112		}
113
114		// Prepare task-specific key:value pairs.
115		if utgArgs.DeviceSpecificBazelConfig == "" {
116			return skerr.Fmt("DeviceSpecificBazelConfig cannot be empty")
117		}
118		deviceSpecificBazelConfig, ok := device_specific_configs.Configs[utgArgs.DeviceSpecificBazelConfig]
119		if !ok {
120			return skerr.Fmt("unknown DeviceSpecificBazelConfig: %q", utgArgs.DeviceSpecificBazelConfig)
121		}
122		var taskSpecificKeyValuePairs []string
123		for k, v := range deviceSpecificBazelConfig.Keys {
124			taskSpecificKeyValuePairs = append(taskSpecificKeyValuePairs, k+":"+v)
125		}
126		sort.Strings(taskSpecificKeyValuePairs) // Sort for determinism.
127
128		// Initialize goldctl.
129		args := []string{
130			"imgtest", "init",
131			"--work-dir", goldctlWorkDir,
132			"--instance", "skia",
133			// If we use flag --instance alone, goldctl will incorrectly infer the Gold instance URL as
134			// https://skia-gold.skia.org.
135			"--url", "https://gold.skia.org",
136			// Similarly, unless we specify a GCE bucket explicitly, goldctl will incorrectly infer
137			// "skia-gold-skia" as the instance's bucket.
138			"--bucket", "skia-infra-gm",
139			"--git_hash", utgArgs.GitCommit,
140		}
141		if utgArgs.ChangelistID != "" && utgArgs.PatchsetOrder != "" {
142			args = append(args,
143				"--crs", "gerrit",
144				"--cis", "buildbucket",
145				"--changelist", utgArgs.ChangelistID,
146				"--patchset", utgArgs.PatchsetOrder,
147				"--jobid", utgArgs.TryjobID)
148		}
149		for _, kv := range taskSpecificKeyValuePairs {
150			args = append(args, "--key", kv)
151		}
152		if err := goldctl(ctx, utgArgs.GoldctlPath, args...); err != nil {
153			return skerr.Wrap(err)
154		}
155
156		// Add PNGs.
157		for _, gmOutput := range gmOutputs {
158			args := []string{
159				"imgtest", "add",
160				"--work-dir", goldctlWorkDir,
161				"--test-name", gmOutput.TestName,
162				"--png-file", gmOutput.PNGPath,
163				"--png-digest", gmOutput.MD5,
164			}
165			var testSpecificKeyValuePairs []string
166			for k, v := range gmOutput.Keys {
167				testSpecificKeyValuePairs = append(testSpecificKeyValuePairs, k+":"+v)
168			}
169			sort.Strings(testSpecificKeyValuePairs) // Sort for determinism.
170			for _, kv := range testSpecificKeyValuePairs {
171				// We assume that all keys are non-optional. That is, all keys are part of the trace. It is
172				// possible to add support for optional keys in the future, which can be specified via the
173				// --add-test-optional-key flag.
174				args = append(args, "--add-test-key", kv)
175			}
176
177			if err := goldctl(ctx, utgArgs.GoldctlPath, args...); err != nil {
178				return skerr.Wrap(err)
179			}
180		}
181
182		// Finalize and upload screenshots to Gold.
183		return goldctl(ctx, utgArgs.GoldctlPath, "imgtest", "finalize", "--work-dir", goldctlWorkDir)
184	})
185}
186
187// gmJSONOutput represents a JSON file produced by //tools/testrunners/gm/BazelGMTestRunner.cpp,
188// plus bookkeeping information required by this task driver.
189type gmJSONOutput struct {
190	MD5  string            `json:"md5"`
191	Keys map[string]string `json:"keys"`
192
193	TestName string `json:"-"` // Convenience alias, should be the same as the "name" key.
194	PNGPath  string `json:"-"`
195}
196
197// gatherGMOutputs inspects a directory with the contents of the undeclared test outputs ZIP
198// archive and gathers any GM outputs found therein.
199func gatherGMOutputs(ctx context.Context, outputsDir string) ([]gmJSONOutput, error) {
200	var outputs []gmJSONOutput
201
202	if err := td.Do(ctx, td.Props("Gather JSON and PNG files produced by GMs"), func(ctx context.Context) error {
203		files, err := os.ReadDir(outputsDir)
204		if err != nil {
205			return skerr.Wrap(err)
206		}
207
208		for _, file := range files {
209			if !strings.HasSuffix(file.Name(), ".json") {
210				continue
211			}
212
213			jsonPath := file.Name()
214			pngPath := strings.TrimSuffix(jsonPath, ".json") + ".png"
215			testName := strings.TrimSuffix(jsonPath, ".json")
216
217			// Skip JSON file if there is no associated PNG file.
218			if _, err := os.Stat(filepath.Join(outputsDir, pngPath)); err != nil {
219				if errors.Is(err, os.ErrNotExist) {
220					if err := td.Do(ctx, td.Props(fmt.Sprintf("Ignoring %q: file %q not found", jsonPath, pngPath)), func(ctx context.Context) error {
221						return nil
222					}); err != nil {
223						return skerr.Wrap(err)
224					}
225					continue
226				} else {
227					return skerr.Wrap(err)
228				}
229			}
230
231			// Parse JSON file. Skip it if parsing fails (rather than failing the entire task in the off
232			// chance that the test has other kinds of undeclared outputs).
233			bytes, err := os.ReadFile(filepath.Join(outputsDir, jsonPath))
234			if err != nil {
235				return skerr.Wrap(err)
236			}
237			output := gmJSONOutput{
238				TestName: testName,
239				PNGPath:  filepath.Join(outputsDir, pngPath),
240			}
241			if err := json.Unmarshal(bytes, &output); err != nil {
242				if err := td.Do(ctx, td.Props(fmt.Sprintf("Ignoring %q; JSON parsing error: %s", jsonPath, err)), func(ctx context.Context) error {
243					return nil
244				}); err != nil {
245					return skerr.Wrap(err)
246				}
247				continue
248			}
249			if output.MD5 == "" {
250				if err := td.Do(ctx, td.Props(fmt.Sprintf(`Ignoring %q: field "md5" not found`, jsonPath)), func(ctx context.Context) error {
251					return nil
252				}); err != nil {
253					return skerr.Wrap(err)
254				}
255				continue
256			}
257
258			// Save GM output.
259			if err := td.Do(ctx, td.Props(fmt.Sprintf("Gather %q", pngPath)), func(ctx context.Context) error {
260				outputs = append(outputs, output)
261				return nil
262			}); err != nil {
263				return skerr.Wrap(err)
264			}
265		}
266
267		return nil
268	}); err != nil {
269		return nil, skerr.Wrap(err)
270	}
271
272	// Sort outputs for determinism.
273	sort.Slice(outputs, func(i, j int) bool {
274		return outputs[i].TestName < outputs[j].TestName
275	})
276
277	return outputs, nil
278}
279
280// goldctl runs the goldctl command.
281func goldctl(ctx context.Context, goldctlPath string, args ...string) error {
282	cmd := &sk_exec.Command{
283		Name:      goldctlPath,
284		Args:      args,
285		LogStdout: true,
286		LogStderr: true,
287	}
288	_, err := sk_exec.RunCommand(ctx, cmd)
289	return skerr.Wrap(err)
290}
291