• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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	b.env("USE_BAZEL_FALLBACK_VERSION", "error")
246}
247
248// usesCCache adds attributes to tasks which use ccache.
249func (b *taskBuilder) usesCCache() {
250	b.cache(CACHES_CCACHE...)
251}
252
253// shellsOutToBazel returns true if this task normally uses GN but some step
254// shells out to Bazel to build stuff, e.g. rust code.
255func (b *taskBuilder) shellsOutToBazel() bool {
256	return b.extraConfig("Vello", "Fontations", "RustPNG")
257}
258
259// usesGit adds attributes to tasks which use git.
260func (b *taskBuilder) usesGit() {
261	b.cache(CACHES_GIT...)
262	if b.isWindows() {
263		b.cipd(specs.CIPD_PKGS_GIT_WINDOWS_AMD64...)
264	} else if b.isMac() {
265		b.cipd(specs.CIPD_PKGS_GIT_MAC_AMD64...)
266	} else if b.isLinux() {
267		b.cipd(specs.CIPD_PKGS_GIT_LINUX_AMD64...)
268	} else {
269		panic("Unknown host OS for " + b.Name)
270	}
271	b.addToPATH("cipd_bin_packages", "cipd_bin_packages/bin")
272}
273
274// usesGo adds attributes to tasks which use go. Recipes should use
275// "with api.context(env=api.infra.go_env)".
276func (b *taskBuilder) usesGo() {
277	b.usesGit() // Go requires Git.
278	b.cache(CACHES_GO...)
279	pkg := b.MustGetCipdPackageFromAsset("go")
280	if b.matchOs("Win") || b.matchExtraConfig("Win") {
281		pkg = b.MustGetCipdPackageFromAsset("go_win")
282		pkg.Path = "go"
283	}
284	b.cipd(pkg)
285	b.addToPATH(pkg.Path + "/go/bin")
286	b.envPrefixes("GOROOT", pkg.Path+"/go")
287}
288
289// usesDocker adds attributes to tasks which use docker.
290func (b *taskBuilder) usesDocker() {
291	b.dimension("docker_installed:true")
292
293	// The "docker" binary reads its config from $HOME/.docker/config.json which, after running
294	// "gcloud auth configure-docker", typically looks like this:
295	//
296	//     {
297	//       "credHelpers": {
298	//         "gcr.io": "gcloud",
299	//         "us.gcr.io": "gcloud",
300	//         "eu.gcr.io": "gcloud",
301	//         "asia.gcr.io": "gcloud",
302	//         "staging-k8s.gcr.io": "gcloud",
303	//         "marketplace.gcr.io": "gcloud"
304	//       }
305	//     }
306	//
307	// This instructs "docker" to get its GCR credentials from a credential helper [1] program
308	// named "docker-credential-gcloud" [2], which is part of the Google Cloud SDK. This program is
309	// a shell script that invokes the "gcloud" command, which is itself a shell script that probes
310	// the environment to find a viable Python interpreter, and then invokes
311	// /usr/lib/google-cloud-sdk/lib/gcloud.py. For some unknown reason, sometimes "gcloud" decides
312	// to use "/b/s/w/ir/cache/vpython/875f1a/bin/python" as the Python interpreter (exact path may
313	// vary), which causes gcloud.py to fail with the following error:
314	//
315	//     ModuleNotFoundError: No module named 'contextlib'
316	//
317	// Fortunately, "gcloud" supports specifying a Python interpreter via the GCLOUDSDK_PYTHON
318	// environment variable.
319	//
320	// [1] https://docs.docker.com/engine/reference/commandline/login/#credential-helpers
321	// [2] See /usr/bin/docker-credential-gcloud on your gLinux system, which is provided by the
322	//     google-cloud-sdk package.
323	b.envPrefixes("CLOUDSDK_PYTHON", "cipd_bin_packages/cpython3/bin/python3")
324
325	// As mentioned, Docker uses gcloud for authentication against GCR, and gcloud requires Python.
326	b.usesPython()
327}
328
329// usesGSUtil adds the gsutil dependency from CIPD and puts it on PATH.
330func (b *taskBuilder) usesGSUtil() {
331	b.asset("gsutil")
332	b.addToPATH("gsutil/gsutil")
333}
334
335// needsFontsForParagraphTests downloads the skparagraph CIPD package to
336// a subdirectory of the Skia checkout: resources/extra_fonts
337func (b *taskBuilder) needsFontsForParagraphTests() {
338	pkg := b.MustGetCipdPackageFromAsset("skparagraph")
339	pkg.Path = "skia/resources/extra_fonts"
340	b.cipd(pkg)
341}
342
343// recipeProp adds the given recipe property key/value pair. Panics if
344// getRecipeProps() was already called.
345func (b *taskBuilder) recipeProp(key, value string) {
346	if b.recipeProperties == nil {
347		log.Fatal("taskBuilder.recipeProp() cannot be called after taskBuilder.getRecipeProps()!")
348	}
349	b.recipeProperties[key] = value
350}
351
352// recipeProps calls recipeProp for every key/value pair in the given map.
353// Panics if getRecipeProps() was already called.
354func (b *taskBuilder) recipeProps(props map[string]string) {
355	for k, v := range props {
356		b.recipeProp(k, v)
357	}
358}
359
360// getRecipeProps returns JSON-encoded recipe properties. Subsequent calls to
361// recipeProp[s] will panic, to prevent accidentally adding recipe properties
362// after they have been added to the task.
363func (b *taskBuilder) getRecipeProps() string {
364	props := make(map[string]interface{}, len(b.recipeProperties)+2)
365	// TODO(borenet): I'm not sure why we supply the original task name
366	// and not the upload task name.  We should investigate whether this is
367	// needed.
368	buildername := b.Name
369	if b.role("Upload") {
370		buildername = strings.TrimPrefix(buildername, "Upload-")
371	}
372	props["buildername"] = buildername
373	props["$kitchen"] = struct {
374		DevShell bool `json:"devshell"`
375		GitAuth  bool `json:"git_auth"`
376	}{
377		DevShell: true,
378		GitAuth:  true,
379	}
380	for k, v := range b.recipeProperties {
381		props[k] = v
382	}
383	b.recipeProperties = nil
384	return marshalJson(props)
385}
386
387// cipdPlatform returns the CIPD platform for this task.
388func (b *taskBuilder) cipdPlatform() string {
389	os, arch := b.goPlatform()
390	if os == "darwin" {
391		os = "mac"
392	}
393	return os + "-" + arch
394}
395
396// usesPython adds attributes to tasks which use python.
397func (b *taskBuilder) usesPython() {
398	// This is sort of a hack to work around the fact that the upstream CIPD
399	// package definitions separate out the platforms for some packages, which
400	// causes some complications when we don't know which platform we're going
401	// to run on, for example Android tasks which may run on RPI in our lab or
402	// on a Linux server elsewhere. Just grab an arbitrary set of Python
403	// packages and then replace the fixed platform with the `${platform}`
404	// placeholder. This does introduce the possibility of failure in cases
405	// where the package does not exist at a given tag for a given platform.
406	fakePlatform := cipd.PlatformLinuxAmd64
407	pythonPkgs, ok := cipd.PkgsPython[fakePlatform]
408	if !ok {
409		panic("No Python packages for platform " + fakePlatform)
410	}
411	for _, pkg := range pythonPkgs {
412		pkg.Name = strings.Replace(pkg.Name, fakePlatform, "${platform}", 1)
413	}
414	b.cipd(pythonPkgs...)
415	b.addToPATH(
416		"cipd_bin_packages/cpython3",
417		"cipd_bin_packages/cpython3/bin",
418	)
419	b.cache(&specs.Cache{
420		Name: "vpython3",
421		Path: "cache/vpython3",
422	})
423	b.envPrefixes("VPYTHON_VIRTUALENV_ROOT", "cache/vpython3")
424	b.env("VPYTHON_LOG_TRACE", "1")
425}
426
427func (b *taskBuilder) usesLUCIAuth() {
428	b.cipd(CIPD_PKG_LUCI_AUTH)
429	b.addToPATH("cipd_bin_packages", "cipd_bin_packages/bin")
430}
431
432func (b *taskBuilder) usesNode() {
433	// It is very important when including node via CIPD to also add it to the PATH of the
434	// taskdriver or mysterious things can happen when subprocesses try to resolve node/npm.
435	b.asset("node")
436	b.addToPATH("node/node/bin")
437}
438
439func (b *taskBuilder) needsLottiesWithAssets() {
440	// This CIPD package was made by hand with the following invocation:
441	//   cipd create -name skia/internal/lotties_with_assets -in ./lotties/ -tag version:2
442	//   cipd acl-edit skia/internal/lotties_with_assets -reader group:project-skia-external-task-accounts
443	//   cipd acl-edit skia/internal/lotties_with_assets -reader user:pool-skia@chromium-swarm.iam.gserviceaccount.com
444	// Where lotties is a hand-selected set of lottie animations and (optionally) assets used in
445	// them (e.g. fonts, images).
446	// Each test case is in its own folder, with a data.json file and an optional images/ subfolder
447	// with any images/fonts/etc loaded by the animation.
448	// Note: If you are downloading the existing package to update them, remove the CIPD-generated
449	// .cipdpkg subfolder before trying to re-upload it.
450	// Note: It is important that the folder names do not special characters like . (), &, as
451	// the Android filesystem does not support folders with those names well.
452	b.cipd(&specs.CipdPackage{
453		Name:    "skia/internal/lotties_with_assets",
454		Path:    "lotties_with_assets",
455		Version: "version:4",
456	})
457}
458
459// goPlatform derives the GOOS and GOARCH for this task.
460func (b *taskBuilder) goPlatform() (string, string) {
461	os := ""
462	if b.isWindows() {
463		os = "windows"
464	} else if b.isMac() {
465		os = "darwin"
466	} else if b.isLinux() || b.matchOs("Android", "ChromeOS", "iOS") {
467		// Tests on Android/ChromeOS/iOS are hosted on RPI.
468		os = "linux"
469	} else {
470		panic("unknown GOOS for " + b.Name)
471	}
472
473	arch := "amd64"
474	if b.role("Upload") {
475		arch = "amd64"
476	} else if b.matchArch("Arm64") || b.matchBazelHost("on_rpi") || b.matchOs("Android", "ChromeOS", "iOS") {
477		// Tests on Android/ChromeOS/iOS are hosted on RPI.
478		// WARNING: This assumption is not necessarily true with Android devices
479		// hosted in other environments.
480		arch = "arm64"
481	} else if b.isLinux() || b.isMac() || b.isWindows() {
482		arch = "amd64"
483	} else {
484		panic("unknown GOARCH for " + b.Name)
485	}
486	return os, arch
487}
488
489// taskDriver sets the task up to use the given task driver, either by depending
490// on the BuildTaskDrivers task to build the task driver immediately before use,
491// or by pulling the pre-built task driver from CIPD. Returns the path to the
492// task driver binary, which can be used directly as part of the task's command.
493func (b *taskBuilder) taskDriver(name string, preBuilt bool) string {
494	if preBuilt {
495		// We assume all task drivers are built under the "skia/tools" prefix
496		// and, being built per-platform, use the ${platform} suffix to
497		// automatically select the correct platform when the task runs.
498		b.cipdFromDEPS("skia/tools/" + name + "/${platform}")
499		// DEPS specifies that task drivers belong in the "task_drivers"
500		// directory.
501		return "task_drivers/" + name
502	} else {
503		os, arch := b.goPlatform()
504		b.dep(b.buildTaskDrivers(os, arch))
505		return "./" + name
506	}
507}
508