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