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) 16 17// taskBuilder is a helper for creating a task. 18type taskBuilder struct { 19 *jobBuilder 20 parts 21 Name string 22 Spec *specs.TaskSpec 23 recipeProperties map[string]string 24} 25 26// newTaskBuilder returns a taskBuilder instance. 27func newTaskBuilder(b *jobBuilder, name string) *taskBuilder { 28 parts, err := b.jobNameSchema.ParseJobName(name) 29 if err != nil { 30 log.Fatal(err) 31 } 32 return &taskBuilder{ 33 jobBuilder: b, 34 parts: parts, 35 Name: name, 36 Spec: &specs.TaskSpec{}, 37 recipeProperties: map[string]string{}, 38 } 39} 40 41// attempts sets the desired MaxAttempts for this task. 42func (b *taskBuilder) attempts(a int) { 43 b.Spec.MaxAttempts = a 44} 45 46// cache adds the given caches to the task. 47func (b *taskBuilder) cache(caches ...*specs.Cache) { 48 for _, c := range caches { 49 alreadyHave := false 50 for _, exist := range b.Spec.Caches { 51 if c.Name == exist.Name { 52 if !reflect.DeepEqual(c, exist) { 53 log.Fatalf("Already have cache %s with a different definition!", c.Name) 54 } 55 alreadyHave = true 56 break 57 } 58 } 59 if !alreadyHave { 60 b.Spec.Caches = append(b.Spec.Caches, c) 61 } 62 } 63} 64 65// cmd sets the command for the task. 66func (b *taskBuilder) cmd(c ...string) { 67 b.Spec.Command = c 68} 69 70// dimension adds the given dimensions to the task. 71func (b *taskBuilder) dimension(dims ...string) { 72 for _, dim := range dims { 73 if !In(dim, b.Spec.Dimensions) { 74 b.Spec.Dimensions = append(b.Spec.Dimensions, dim) 75 } 76 } 77} 78 79// expiration sets the expiration of the task. 80func (b *taskBuilder) expiration(e time.Duration) { 81 b.Spec.Expiration = e 82} 83 84// idempotent marks the task as idempotent. 85func (b *taskBuilder) idempotent() { 86 b.Spec.Idempotent = true 87} 88 89// cas sets the CasSpec used by the task. 90func (b *taskBuilder) cas(casSpec string) { 91 b.Spec.CasSpec = casSpec 92} 93 94// env sets the value for the given environment variable for the task. 95func (b *taskBuilder) env(key, value string) { 96 if b.Spec.Environment == nil { 97 b.Spec.Environment = map[string]string{} 98 } 99 b.Spec.Environment[key] = value 100} 101 102// envPrefixes appends the given values to the given environment variable for 103// the task. 104func (b *taskBuilder) envPrefixes(key string, values ...string) { 105 if b.Spec.EnvPrefixes == nil { 106 b.Spec.EnvPrefixes = map[string][]string{} 107 } 108 for _, value := range values { 109 if !In(value, b.Spec.EnvPrefixes[key]) { 110 b.Spec.EnvPrefixes[key] = append(b.Spec.EnvPrefixes[key], value) 111 } 112 } 113} 114 115// addToPATH adds the given locations to PATH for the task. 116func (b *taskBuilder) addToPATH(loc ...string) { 117 b.envPrefixes("PATH", loc...) 118} 119 120// output adds the given paths as outputs to the task, which results in their 121// contents being uploaded to the isolate server. 122func (b *taskBuilder) output(paths ...string) { 123 for _, path := range paths { 124 if !In(path, b.Spec.Outputs) { 125 b.Spec.Outputs = append(b.Spec.Outputs, path) 126 } 127 } 128} 129 130// serviceAccount sets the service account for this task. 131func (b *taskBuilder) serviceAccount(sa string) { 132 b.Spec.ServiceAccount = sa 133} 134 135// timeout sets the timeout(s) for this task. 136func (b *taskBuilder) timeout(timeout time.Duration) { 137 b.Spec.ExecutionTimeout = timeout 138 b.Spec.IoTimeout = timeout // With kitchen, step logs don't count toward IoTimeout. 139} 140 141// dep adds the given tasks as dependencies of this task. 142func (b *taskBuilder) dep(tasks ...string) { 143 for _, task := range tasks { 144 if !In(task, b.Spec.Dependencies) { 145 b.Spec.Dependencies = append(b.Spec.Dependencies, task) 146 } 147 } 148} 149 150// cipd adds the given CIPD packages to the task. 151func (b *taskBuilder) cipd(pkgs ...*specs.CipdPackage) { 152 for _, pkg := range pkgs { 153 alreadyHave := false 154 for _, exist := range b.Spec.CipdPackages { 155 if pkg.Name == exist.Name { 156 if !reflect.DeepEqual(pkg, exist) { 157 log.Fatalf("Already have package %s with a different definition!", pkg.Name) 158 } 159 alreadyHave = true 160 break 161 } 162 } 163 if !alreadyHave { 164 b.Spec.CipdPackages = append(b.Spec.CipdPackages, pkg) 165 } 166 } 167} 168 169// useIsolatedAssets returns true if this task should use assets which are 170// isolated rather than downloading directly from CIPD. 171func (b *taskBuilder) useIsolatedAssets() bool { 172 // Only do this on the RPIs for now. Other, faster machines shouldn't 173 // see much benefit and we don't need the extra complexity, for now. 174 if b.os("ChromeOS", "iOS") || b.matchOs("Android") { 175 return true 176 } 177 return false 178} 179 180// uploadAssetCASCfg represents a task which copies a CIPD package into 181// isolate. 182type uploadAssetCASCfg struct { 183 alwaysIsolate bool 184 uploadTaskName string 185 path string 186} 187 188// assetWithVersion adds the given asset with the given version number to the 189// task as a CIPD package. 190func (b *taskBuilder) assetWithVersion(assetName string, version int) { 191 pkg := &specs.CipdPackage{ 192 Name: fmt.Sprintf("skia/bots/%s", assetName), 193 Path: assetName, 194 Version: fmt.Sprintf("version:%d", version), 195 } 196 b.cipd(pkg) 197} 198 199// asset adds the given assets to the task as CIPD packages. 200func (b *taskBuilder) asset(assets ...string) { 201 shouldIsolate := b.useIsolatedAssets() 202 pkgs := make([]*specs.CipdPackage, 0, len(assets)) 203 for _, asset := range assets { 204 if cfg, ok := ISOLATE_ASSET_MAPPING[asset]; ok && (cfg.alwaysIsolate || shouldIsolate) { 205 b.dep(b.uploadCIPDAssetToCAS(asset)) 206 } else { 207 pkgs = append(pkgs, b.MustGetCipdPackageFromAsset(asset)) 208 } 209 } 210 b.cipd(pkgs...) 211} 212 213// usesCCache adds attributes to tasks which need bazel (via bazelisk). 214func (b *taskBuilder) usesBazel(hostOSArch string) { 215 archToPkg := map[string]string{ 216 "linux_x64": "bazelisk_linux_amd64", 217 "mac_x64": "bazelisk_mac_amd64", 218 } 219 pkg, ok := archToPkg[hostOSArch] 220 if !ok { 221 panic("Unsupported osAndArch for bazelisk: " + hostOSArch) 222 } 223 b.cipd(b.MustGetCipdPackageFromAsset(pkg)) 224 b.addToPATH(pkg) 225} 226 227// usesCCache adds attributes to tasks which use ccache. 228func (b *taskBuilder) usesCCache() { 229 b.cache(CACHES_CCACHE...) 230} 231 232// usesGit adds attributes to tasks which use git. 233func (b *taskBuilder) usesGit() { 234 b.cache(CACHES_GIT...) 235 if b.matchOs("Win") || b.matchExtraConfig("Win") { 236 b.cipd(specs.CIPD_PKGS_GIT_WINDOWS_AMD64...) 237 } else if b.matchOs("Mac") || b.matchExtraConfig("Mac") { 238 b.cipd(specs.CIPD_PKGS_GIT_MAC_AMD64...) 239 } else { 240 b.cipd(specs.CIPD_PKGS_GIT_LINUX_AMD64...) 241 } 242} 243 244// usesGo adds attributes to tasks which use go. Recipes should use 245// "with api.context(env=api.infra.go_env)". 246func (b *taskBuilder) usesGo() { 247 b.usesGit() // Go requires Git. 248 b.cache(CACHES_GO...) 249 pkg := b.MustGetCipdPackageFromAsset("go") 250 if b.matchOs("Win") || b.matchExtraConfig("Win") { 251 pkg = b.MustGetCipdPackageFromAsset("go_win") 252 pkg.Path = "go" 253 } 254 b.cipd(pkg) 255 b.addToPATH(pkg.Path + "/go/bin") 256 b.envPrefixes("GOROOT", pkg.Path+"/go") 257} 258 259// usesDocker adds attributes to tasks which use docker. 260func (b *taskBuilder) usesDocker() { 261 b.dimension("docker_installed:true") 262 263 // The "docker" binary reads its config from $HOME/.docker/config.json which, after running 264 // "gcloud auth configure-docker", typically looks like this: 265 // 266 // { 267 // "credHelpers": { 268 // "gcr.io": "gcloud", 269 // "us.gcr.io": "gcloud", 270 // "eu.gcr.io": "gcloud", 271 // "asia.gcr.io": "gcloud", 272 // "staging-k8s.gcr.io": "gcloud", 273 // "marketplace.gcr.io": "gcloud" 274 // } 275 // } 276 // 277 // This instructs "docker" to get its GCR credentials from a credential helper [1] program 278 // named "docker-credential-gcloud" [2], which is part of the Google Cloud SDK. This program is 279 // a shell script that invokes the "gcloud" command, which is itself a shell script that probes 280 // the environment to find a viable Python interpreter, and then invokes 281 // /usr/lib/google-cloud-sdk/lib/gcloud.py. For some unknown reason, sometimes "gcloud" decides 282 // to use "/b/s/w/ir/cache/vpython/875f1a/bin/python" as the Python interpreter (exact path may 283 // vary), which causes gcloud.py to fail with the following error: 284 // 285 // ModuleNotFoundError: No module named 'contextlib' 286 // 287 // Fortunately, "gcloud" supports specifying a Python interpreter via the GCLOUDSDK_PYTHON 288 // environment variable. 289 // 290 // [1] https://docs.docker.com/engine/reference/commandline/login/#credential-helpers 291 // [2] See /usr/bin/docker-credential-gcloud on your gLinux system, which is provided by the 292 // google-cloud-sdk package. 293 b.envPrefixes("CLOUDSDK_PYTHON", "cipd_bin_packages/cpython3/bin/python3") 294 295 // As mentioned, Docker uses gcloud for authentication against GCR, and gcloud requires Python. 296 b.usesPython() 297} 298 299// usesGSUtil adds the gsutil dependency from CIPD and puts it on PATH. 300func (b *taskBuilder) usesGSUtil() { 301 b.asset("gsutil") 302 b.addToPATH("gsutil/gsutil") 303} 304 305// needsFontsForParagraphTests downloads the skparagraph CIPD package to 306// a subdirectory of the Skia checkout: resources/extra_fonts 307func (b *taskBuilder) needsFontsForParagraphTests() { 308 pkg := b.MustGetCipdPackageFromAsset("skparagraph") 309 pkg.Path = "skia/resources/extra_fonts" 310 b.cipd(pkg) 311} 312 313// recipeProp adds the given recipe property key/value pair. Panics if 314// getRecipeProps() was already called. 315func (b *taskBuilder) recipeProp(key, value string) { 316 if b.recipeProperties == nil { 317 log.Fatal("taskBuilder.recipeProp() cannot be called after taskBuilder.getRecipeProps()!") 318 } 319 b.recipeProperties[key] = value 320} 321 322// recipeProps calls recipeProp for every key/value pair in the given map. 323// Panics if getRecipeProps() was already called. 324func (b *taskBuilder) recipeProps(props map[string]string) { 325 for k, v := range props { 326 b.recipeProp(k, v) 327 } 328} 329 330// getRecipeProps returns JSON-encoded recipe properties. Subsequent calls to 331// recipeProp[s] will panic, to prevent accidentally adding recipe properties 332// after they have been added to the task. 333func (b *taskBuilder) getRecipeProps() string { 334 props := make(map[string]interface{}, len(b.recipeProperties)+2) 335 // TODO(borenet): I'm not sure why we supply the original task name 336 // and not the upload task name. We should investigate whether this is 337 // needed. 338 buildername := b.Name 339 if b.role("Upload") { 340 buildername = strings.TrimPrefix(buildername, "Upload-") 341 } 342 props["buildername"] = buildername 343 props["$kitchen"] = struct { 344 DevShell bool `json:"devshell"` 345 GitAuth bool `json:"git_auth"` 346 }{ 347 DevShell: true, 348 GitAuth: true, 349 } 350 for k, v := range b.recipeProperties { 351 props[k] = v 352 } 353 b.recipeProperties = nil 354 return marshalJson(props) 355} 356 357// cipdPlatform returns the CIPD platform for this task. 358func (b *taskBuilder) cipdPlatform() string { 359 if b.role("Upload") { 360 return cipd.PlatformLinuxAmd64 361 } else if b.matchOs("Win") || b.matchExtraConfig("Win") { 362 return cipd.PlatformWindowsAmd64 363 } else if b.matchOs("Mac") { 364 return cipd.PlatformMacAmd64 365 } else if b.matchArch("Arm64") { 366 return cipd.PlatformLinuxArm64 367 } else if b.matchOs("Android", "ChromeOS") { 368 return cipd.PlatformLinuxArm64 369 } else if b.matchOs("iOS") { 370 return cipd.PlatformLinuxArm64 371 } else { 372 return cipd.PlatformLinuxAmd64 373 } 374} 375 376// usesPython adds attributes to tasks which use python. 377func (b *taskBuilder) usesPython() { 378 pythonPkgs := cipd.PkgsPython[b.cipdPlatform()] 379 b.cipd(pythonPkgs...) 380 b.addToPATH( 381 "cipd_bin_packages/cpython3", 382 "cipd_bin_packages/cpython3/bin", 383 ) 384 b.cache(&specs.Cache{ 385 Name: "vpython3", 386 Path: "cache/vpython3", 387 }) 388 b.envPrefixes("VPYTHON_VIRTUALENV_ROOT", "cache/vpython3") 389 b.env("VPYTHON_LOG_TRACE", "1") 390} 391 392func (b *taskBuilder) usesNode() { 393 // It is very important when including node via CIPD to also add it to the PATH of the 394 // taskdriver or mysterious things can happen when subprocesses try to resolve node/npm. 395 b.asset("node") 396 b.addToPATH("node/node/bin") 397} 398 399func (b *taskBuilder) needsLottiesWithAssets() { 400 // This CIPD package was made by hand with the following invocation: 401 // cipd create -name skia/internal/lotties_with_assets -in ./lotties/ -tag version:2 402 // cipd acl-edit skia/internal/lotties_with_assets -reader group:project-skia-external-task-accounts 403 // cipd acl-edit skia/internal/lotties_with_assets -reader user:pool-skia@chromium-swarm.iam.gserviceaccount.com 404 // Where lotties is a hand-selected set of lottie animations and (optionally) assets used in 405 // them (e.g. fonts, images). 406 // Each test case is in its own folder, with a data.json file and an optional images/ subfolder 407 // with any images/fonts/etc loaded by the animation. 408 // Note: If you are downloading the existing package to update them, remove the CIPD-generated 409 // .cipdpkg subfolder before trying to re-upload it. 410 // Note: It is important that the folder names do not special characters like . (), &, as 411 // the Android filesystem does not support folders with those names well. 412 b.cipd(&specs.CipdPackage{ 413 Name: "skia/internal/lotties_with_assets", 414 Path: "lotties_with_assets", 415 Version: "version:4", 416 }) 417} 418