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