xref: /aosp_15_r20/external/skia/infra/bots/gen_tasks_logic/task_builder.go (revision c8dee2aa9b3f27cf6c858bd81872bdeb2c07ed17)
1// Copyright 2020 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4package gen_tasks_logic
5
6import (
7	"fmt"
8	"log"
9	"reflect"
10	"strings"
11	"time"
12
13	"go.skia.org/infra/go/cipd"
14	"go.skia.org/infra/task_scheduler/go/specs"
15	"go.skia.org/skia/infra/bots/deps"
16)
17
18// taskBuilder is a helper for creating a task.
19type taskBuilder struct {
20	*jobBuilder
21	parts
22	Name             string
23	Spec             *specs.TaskSpec
24	recipeProperties map[string]string
25}
26
27// newTaskBuilder returns a taskBuilder instance.
28func newTaskBuilder(b *jobBuilder, name string) *taskBuilder {
29	parts, err := b.jobNameSchema.ParseJobName(name)
30	if err != nil {
31		log.Fatal(err)
32	}
33	return &taskBuilder{
34		jobBuilder:       b,
35		parts:            parts,
36		Name:             name,
37		Spec:             &specs.TaskSpec{},
38		recipeProperties: map[string]string{},
39	}
40}
41
42// attempts sets the desired MaxAttempts for this task.
43func (b *taskBuilder) attempts(a int) {
44	b.Spec.MaxAttempts = a
45}
46
47// cache adds the given caches to the task.
48func (b *taskBuilder) cache(caches ...*specs.Cache) {
49	for _, c := range caches {
50		alreadyHave := false
51		for _, exist := range b.Spec.Caches {
52			if c.Name == exist.Name {
53				if !reflect.DeepEqual(c, exist) {
54					log.Fatalf("Already have cache %s with a different definition!", c.Name)
55				}
56				alreadyHave = true
57				break
58			}
59		}
60		if !alreadyHave {
61			b.Spec.Caches = append(b.Spec.Caches, c)
62		}
63	}
64}
65
66// cmd sets the command for the task.
67func (b *taskBuilder) cmd(c ...string) {
68	b.Spec.Command = c
69}
70
71// dimension adds the given dimensions to the task.
72func (b *taskBuilder) dimension(dims ...string) {
73	for _, dim := range dims {
74		if !In(dim, b.Spec.Dimensions) {
75			b.Spec.Dimensions = append(b.Spec.Dimensions, dim)
76		}
77	}
78}
79
80// expiration sets the expiration of the task.
81func (b *taskBuilder) expiration(e time.Duration) {
82	b.Spec.Expiration = e
83}
84
85// idempotent marks the task as idempotent.
86func (b *taskBuilder) idempotent() {
87	b.Spec.Idempotent = true
88}
89
90// cas sets the CasSpec used by the task.
91func (b *taskBuilder) cas(casSpec string) {
92	b.Spec.CasSpec = casSpec
93}
94
95// env sets the value for the given environment variable for the task.
96func (b *taskBuilder) env(key, value string) {
97	if b.Spec.Environment == nil {
98		b.Spec.Environment = map[string]string{}
99	}
100	b.Spec.Environment[key] = value
101}
102
103// envPrefixes appends the given values to the given environment variable for
104// the task.
105func (b *taskBuilder) envPrefixes(key string, values ...string) {
106	if b.Spec.EnvPrefixes == nil {
107		b.Spec.EnvPrefixes = map[string][]string{}
108	}
109	for _, value := range values {
110		if !In(value, b.Spec.EnvPrefixes[key]) {
111			b.Spec.EnvPrefixes[key] = append(b.Spec.EnvPrefixes[key], value)
112		}
113	}
114}
115
116// addToPATH adds the given locations to PATH for the task.
117func (b *taskBuilder) addToPATH(loc ...string) {
118	b.envPrefixes("PATH", loc...)
119}
120
121// output adds the given paths as outputs to the task, which results in their
122// contents being uploaded to the isolate server.
123func (b *taskBuilder) output(paths ...string) {
124	for _, path := range paths {
125		if !In(path, b.Spec.Outputs) {
126			b.Spec.Outputs = append(b.Spec.Outputs, path)
127		}
128	}
129}
130
131// serviceAccount sets the service account for this task.
132func (b *taskBuilder) serviceAccount(sa string) {
133	b.Spec.ServiceAccount = sa
134}
135
136// timeout sets the timeout(s) for this task.
137func (b *taskBuilder) timeout(timeout time.Duration) {
138	b.Spec.ExecutionTimeout = timeout
139	b.Spec.IoTimeout = timeout // With kitchen, step logs don't count toward IoTimeout.
140}
141
142// dep adds the given tasks as dependencies of this task.
143func (b *taskBuilder) dep(tasks ...string) {
144	for _, task := range tasks {
145		if !In(task, b.Spec.Dependencies) {
146			b.Spec.Dependencies = append(b.Spec.Dependencies, task)
147		}
148	}
149}
150
151// cipd adds the given CIPD packages to the task.
152func (b *taskBuilder) cipd(pkgs ...*specs.CipdPackage) {
153	for _, pkg := range pkgs {
154		alreadyHave := false
155		for _, exist := range b.Spec.CipdPackages {
156			if pkg.Name == exist.Name {
157				if !reflect.DeepEqual(pkg, exist) {
158					log.Fatalf("Already have package %s with a different definition!", pkg.Name)
159				}
160				alreadyHave = true
161				break
162			}
163		}
164		if !alreadyHave {
165			b.Spec.CipdPackages = append(b.Spec.CipdPackages, pkg)
166		}
167	}
168}
169
170// cipdFromDEPS adds a CIPD package, which is pinned in DEPS, to the task.
171func (b *taskBuilder) cipdFromDEPS(pkgName string) {
172	dep, err := deps.Get(pkgName)
173	if err != nil {
174		panic(err)
175	}
176	taskDriverPkg := &cipd.Package{
177		// Note: the DEPS parser normalizes dependency IDs, which includes
178		// stripping suffixes like "/${platform}" or ".git". When specifying a
179		// package to a Swarming task, those suffixes are necessary, so we use
180		// the passed-in package name, which we assume is correct and complete.
181		Name:    pkgName,
182		Path:    dep.Path,
183		Version: dep.Version,
184	}
185	b.cipd(taskDriverPkg)
186}
187
188// useIsolatedAssets returns true if this task should use assets which are
189// isolated rather than downloading directly from CIPD.
190func (b *taskBuilder) useIsolatedAssets() bool {
191	// Only do this on the RPIs for now. Other, faster machines shouldn't
192	// see much benefit and we don't need the extra complexity, for now.
193	if b.os("ChromeOS", "iOS") || b.matchOs("Android") {
194		return true
195	}
196	return false
197}
198
199// uploadAssetCASCfg represents a task which copies a CIPD package into
200// isolate.
201type uploadAssetCASCfg struct {
202	alwaysIsolate  bool
203	uploadTaskName string
204	path           string
205}
206
207// assetWithVersion adds the given asset with the given version number to the
208// task as a CIPD package.
209func (b *taskBuilder) assetWithVersion(assetName string, version int) {
210	pkg := &specs.CipdPackage{
211		Name:    fmt.Sprintf("skia/bots/%s", assetName),
212		Path:    assetName,
213		Version: fmt.Sprintf("version:%d", version),
214	}
215	b.cipd(pkg)
216}
217
218// asset adds the given assets to the task as CIPD packages.
219func (b *taskBuilder) asset(assets ...string) {
220	shouldIsolate := b.useIsolatedAssets()
221	pkgs := make([]*specs.CipdPackage, 0, len(assets))
222	for _, asset := range assets {
223		if cfg, ok := ISOLATE_ASSET_MAPPING[asset]; ok && (cfg.alwaysIsolate || shouldIsolate) {
224			b.dep(b.uploadCIPDAssetToCAS(asset))
225		} else {
226			pkgs = append(pkgs, b.MustGetCipdPackageFromAsset(asset))
227		}
228	}
229	b.cipd(pkgs...)
230}
231
232// usesCCache adds attributes to tasks which need bazel (via bazelisk).
233func (b *taskBuilder) usesBazel(hostOSArch string) {
234	archToPkg := map[string]string{
235		"linux_x64":   "bazelisk_linux_amd64",
236		"mac_x64":     "bazelisk_mac_amd64",
237		"windows_x64": "bazelisk_win_amd64",
238	}
239	pkg, ok := archToPkg[hostOSArch]
240	if !ok {
241		panic("Unsupported osAndArch for bazelisk: " + hostOSArch)
242	}
243	b.cipd(b.MustGetCipdPackageFromAsset(pkg))
244	b.addToPATH(pkg)
245}
246
247// usesCCache adds attributes to tasks which use ccache.
248func (b *taskBuilder) usesCCache() {
249	b.cache(CACHES_CCACHE...)
250}
251
252// shellsOutToBazel returns true if this task normally uses GN but some step
253// shells out to Bazel to build stuff, e.g. rust code.
254func (b *taskBuilder) shellsOutToBazel() bool {
255	return b.extraConfig("Vello", "Fontations", "RustPNG")
256}
257
258// usesGit adds attributes to tasks which use git.
259func (b *taskBuilder) usesGit() {
260	b.cache(CACHES_GIT...)
261	if b.isWindows() {
262		b.cipd(specs.CIPD_PKGS_GIT_WINDOWS_AMD64...)
263	} else if b.isMac() {
264		b.cipd(specs.CIPD_PKGS_GIT_MAC_AMD64...)
265	} else if b.isLinux() {
266		b.cipd(specs.CIPD_PKGS_GIT_LINUX_AMD64...)
267	} else {
268		panic("Unknown host OS for " + b.Name)
269	}
270	b.addToPATH("cipd_bin_packages", "cipd_bin_packages/bin")
271}
272
273// usesGo adds attributes to tasks which use go. Recipes should use
274// "with api.context(env=api.infra.go_env)".
275func (b *taskBuilder) usesGo() {
276	b.usesGit() // Go requires Git.
277	b.cache(CACHES_GO...)
278	pkg := b.MustGetCipdPackageFromAsset("go")
279	if b.matchOs("Win") || b.matchExtraConfig("Win") {
280		pkg = b.MustGetCipdPackageFromAsset("go_win")
281		pkg.Path = "go"
282	}
283	b.cipd(pkg)
284	b.addToPATH(pkg.Path + "/go/bin")
285	b.envPrefixes("GOROOT", pkg.Path+"/go")
286}
287
288// usesDocker adds attributes to tasks which use docker.
289func (b *taskBuilder) usesDocker() {
290	b.dimension("docker_installed:true")
291
292	// The "docker" binary reads its config from $HOME/.docker/config.json which, after running
293	// "gcloud auth configure-docker", typically looks like this:
294	//
295	//     {
296	//       "credHelpers": {
297	//         "gcr.io": "gcloud",
298	//         "us.gcr.io": "gcloud",
299	//         "eu.gcr.io": "gcloud",
300	//         "asia.gcr.io": "gcloud",
301	//         "staging-k8s.gcr.io": "gcloud",
302	//         "marketplace.gcr.io": "gcloud"
303	//       }
304	//     }
305	//
306	// This instructs "docker" to get its GCR credentials from a credential helper [1] program
307	// named "docker-credential-gcloud" [2], which is part of the Google Cloud SDK. This program is
308	// a shell script that invokes the "gcloud" command, which is itself a shell script that probes
309	// the environment to find a viable Python interpreter, and then invokes
310	// /usr/lib/google-cloud-sdk/lib/gcloud.py. For some unknown reason, sometimes "gcloud" decides
311	// to use "/b/s/w/ir/cache/vpython/875f1a/bin/python" as the Python interpreter (exact path may
312	// vary), which causes gcloud.py to fail with the following error:
313	//
314	//     ModuleNotFoundError: No module named 'contextlib'
315	//
316	// Fortunately, "gcloud" supports specifying a Python interpreter via the GCLOUDSDK_PYTHON
317	// environment variable.
318	//
319	// [1] https://docs.docker.com/engine/reference/commandline/login/#credential-helpers
320	// [2] See /usr/bin/docker-credential-gcloud on your gLinux system, which is provided by the
321	//     google-cloud-sdk package.
322	b.envPrefixes("CLOUDSDK_PYTHON", "cipd_bin_packages/cpython3/bin/python3")
323
324	// As mentioned, Docker uses gcloud for authentication against GCR, and gcloud requires Python.
325	b.usesPython()
326}
327
328// usesGSUtil adds the gsutil dependency from CIPD and puts it on PATH.
329func (b *taskBuilder) usesGSUtil() {
330	b.asset("gsutil")
331	b.addToPATH("gsutil/gsutil")
332}
333
334// needsFontsForParagraphTests downloads the skparagraph CIPD package to
335// a subdirectory of the Skia checkout: resources/extra_fonts
336func (b *taskBuilder) needsFontsForParagraphTests() {
337	pkg := b.MustGetCipdPackageFromAsset("skparagraph")
338	pkg.Path = "skia/resources/extra_fonts"
339	b.cipd(pkg)
340}
341
342// recipeProp adds the given recipe property key/value pair. Panics if
343// getRecipeProps() was already called.
344func (b *taskBuilder) recipeProp(key, value string) {
345	if b.recipeProperties == nil {
346		log.Fatal("taskBuilder.recipeProp() cannot be called after taskBuilder.getRecipeProps()!")
347	}
348	b.recipeProperties[key] = value
349}
350
351// recipeProps calls recipeProp for every key/value pair in the given map.
352// Panics if getRecipeProps() was already called.
353func (b *taskBuilder) recipeProps(props map[string]string) {
354	for k, v := range props {
355		b.recipeProp(k, v)
356	}
357}
358
359// getRecipeProps returns JSON-encoded recipe properties. Subsequent calls to
360// recipeProp[s] will panic, to prevent accidentally adding recipe properties
361// after they have been added to the task.
362func (b *taskBuilder) getRecipeProps() string {
363	props := make(map[string]interface{}, len(b.recipeProperties)+2)
364	// TODO(borenet): I'm not sure why we supply the original task name
365	// and not the upload task name.  We should investigate whether this is
366	// needed.
367	buildername := b.Name
368	if b.role("Upload") {
369		buildername = strings.TrimPrefix(buildername, "Upload-")
370	}
371	props["buildername"] = buildername
372	props["$kitchen"] = struct {
373		DevShell bool `json:"devshell"`
374		GitAuth  bool `json:"git_auth"`
375	}{
376		DevShell: true,
377		GitAuth:  true,
378	}
379	for k, v := range b.recipeProperties {
380		props[k] = v
381	}
382	b.recipeProperties = nil
383	return marshalJson(props)
384}
385
386// cipdPlatform returns the CIPD platform for this task.
387func (b *taskBuilder) cipdPlatform() string {
388	os, arch := b.goPlatform()
389	if os == "darwin" {
390		os = "mac"
391	}
392	return os + "-" + arch
393}
394
395// usesPython adds attributes to tasks which use python.
396func (b *taskBuilder) usesPython() {
397	// This is sort of a hack to work around the fact that the upstream CIPD
398	// package definitions separate out the platforms for some packages, which
399	// causes some complications when we don't know which platform we're going
400	// to run on, for example Android tasks which may run on RPI in our lab or
401	// on a Linux server elsewhere. Just grab an arbitrary set of Python
402	// packages and then replace the fixed platform with the `${platform}`
403	// placeholder. This does introduce the possibility of failure in cases
404	// where the package does not exist at a given tag for a given platform.
405	fakePlatform := cipd.PlatformLinuxAmd64
406	pythonPkgs, ok := cipd.PkgsPython[fakePlatform]
407	if !ok {
408		panic("No Python packages for platform " + fakePlatform)
409	}
410	for _, pkg := range pythonPkgs {
411		pkg.Name = strings.Replace(pkg.Name, fakePlatform, "${platform}", 1)
412	}
413	b.cipd(pythonPkgs...)
414	b.addToPATH(
415		"cipd_bin_packages/cpython3",
416		"cipd_bin_packages/cpython3/bin",
417	)
418	b.cache(&specs.Cache{
419		Name: "vpython3",
420		Path: "cache/vpython3",
421	})
422	b.envPrefixes("VPYTHON_VIRTUALENV_ROOT", "cache/vpython3")
423	b.env("VPYTHON_LOG_TRACE", "1")
424}
425
426func (b *taskBuilder) usesLUCIAuth() {
427	b.cipd(CIPD_PKG_LUCI_AUTH)
428	b.addToPATH("cipd_bin_packages", "cipd_bin_packages/bin")
429}
430
431func (b *taskBuilder) usesNode() {
432	// It is very important when including node via CIPD to also add it to the PATH of the
433	// taskdriver or mysterious things can happen when subprocesses try to resolve node/npm.
434	b.asset("node")
435	b.addToPATH("node/node/bin")
436}
437
438func (b *taskBuilder) needsLottiesWithAssets() {
439	// This CIPD package was made by hand with the following invocation:
440	//   cipd create -name skia/internal/lotties_with_assets -in ./lotties/ -tag version:2
441	//   cipd acl-edit skia/internal/lotties_with_assets -reader group:project-skia-external-task-accounts
442	//   cipd acl-edit skia/internal/lotties_with_assets -reader user:[email protected]
443	// Where lotties is a hand-selected set of lottie animations and (optionally) assets used in
444	// them (e.g. fonts, images).
445	// Each test case is in its own folder, with a data.json file and an optional images/ subfolder
446	// with any images/fonts/etc loaded by the animation.
447	// Note: If you are downloading the existing package to update them, remove the CIPD-generated
448	// .cipdpkg subfolder before trying to re-upload it.
449	// Note: It is important that the folder names do not special characters like . (), &, as
450	// the Android filesystem does not support folders with those names well.
451	b.cipd(&specs.CipdPackage{
452		Name:    "skia/internal/lotties_with_assets",
453		Path:    "lotties_with_assets",
454		Version: "version:4",
455	})
456}
457
458// goPlatform derives the GOOS and GOARCH for this task.
459func (b *taskBuilder) goPlatform() (string, string) {
460	os := ""
461	if b.isWindows() {
462		os = "windows"
463	} else if b.isMac() {
464		os = "darwin"
465	} else if b.isLinux() || b.matchOs("Android", "ChromeOS", "iOS") {
466		// Tests on Android/ChromeOS/iOS are hosted on RPI.
467		os = "linux"
468	} else {
469		panic("unknown GOOS for " + b.Name)
470	}
471
472	arch := "amd64"
473	if b.role("Upload") {
474		arch = "amd64"
475	} else if b.matchArch("Arm64") || b.matchBazelHost("on_rpi") || b.matchOs("Android", "ChromeOS", "iOS") {
476		// Tests on Android/ChromeOS/iOS are hosted on RPI.
477		// WARNING: This assumption is not necessarily true with Android devices
478		// hosted in other environments.
479		arch = "arm64"
480	} else if b.isLinux() || b.isMac() || b.isWindows() {
481		arch = "amd64"
482	} else {
483		panic("unknown GOARCH for " + b.Name)
484	}
485	return os, arch
486}
487
488// taskDriver sets the task up to use the given task driver, either by depending
489// on the BuildTaskDrivers task to build the task driver immediately before use,
490// or by pulling the pre-built task driver from CIPD. Returns the path to the
491// task driver binary, which can be used directly as part of the task's command.
492func (b *taskBuilder) taskDriver(name string, preBuilt bool) string {
493	if preBuilt {
494		// We assume all task drivers are built under the "skia/tools" prefix
495		// and, being built per-platform, use the ${platform} suffix to
496		// automatically select the correct platform when the task runs.
497		b.cipdFromDEPS("skia/tools/" + name + "/${platform}")
498		// DEPS specifies that task drivers belong in the "task_drivers"
499		// directory.
500		return "task_drivers/" + name
501	} else {
502		os, arch := b.goPlatform()
503		b.dep(b.buildTaskDrivers(os, arch))
504		return "./" + name
505	}
506}
507