| OLD | NEW |
| 1 # coding: utf-8 | 1 # coding: utf-8 |
| 2 # Copyright 2014 The LUCI Authors. All rights reserved. | 2 # Copyright 2014 The LUCI Authors. All rights reserved. |
| 3 # Use of this source code is governed by the Apache v2.0 license that can be | 3 # Use of this source code is governed by the Apache v2.0 license that can be |
| 4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
| 5 | 5 |
| 6 """Tasks definition. | 6 """Tasks definition. |
| 7 | 7 |
| 8 Each user request creates a new TaskRequest. The TaskRequest instance saves the | 8 Each user request creates a new TaskRequest. The TaskRequest instance saves the |
| 9 metadata of the request, e.g. who requested it, when why, etc. It links to the | 9 metadata of the request, e.g. who requested it, when why, etc. It links to the |
| 10 actual data of the request in a TaskProperties. The TaskProperties represents | 10 actual data of the request in a TaskProperties. The TaskProperties represents |
| (...skipping 30 matching lines...) Expand all Loading... |
| 41 | | 41 | |
| 42 <See task_to_run.py and task_result.py> | 42 <See task_to_run.py and task_result.py> |
| 43 | 43 |
| 44 TaskProperties is embedded in TaskRequest. TaskProperties is still declared as a | 44 TaskProperties is embedded in TaskRequest. TaskProperties is still declared as a |
| 45 separate entity to clearly declare the boundary for task request deduplication. | 45 separate entity to clearly declare the boundary for task request deduplication. |
| 46 """ | 46 """ |
| 47 | 47 |
| 48 | 48 |
| 49 import datetime | 49 import datetime |
| 50 import hashlib | 50 import hashlib |
| 51 import logging | |
| 52 import random | 51 import random |
| 53 import re | 52 import re |
| 54 import urlparse | 53 import urlparse |
| 55 | 54 |
| 56 from google.appengine.api import datastore_errors | 55 from google.appengine.api import datastore_errors |
| 57 from google.appengine.ext import ndb | 56 from google.appengine.ext import ndb |
| 58 | 57 |
| 59 from components import auth | 58 from components import auth |
| 60 from components import datastore_utils | 59 from components import datastore_utils |
| 61 from components import pubsub | 60 from components import pubsub |
| 62 from components import utils | 61 from components import utils |
| 62 |
| 63 from server import config |
| 63 from server import task_pack | 64 from server import task_pack |
| 64 import cipd | 65 import cipd |
| 65 | 66 |
| 66 | 67 |
| 67 # Maximum acceptable priority value, which is effectively the lowest priority. | 68 # Maximum acceptable priority value, which is effectively the lowest priority. |
| 68 MAXIMUM_PRIORITY = 255 | 69 MAXIMUM_PRIORITY = 255 |
| 69 | 70 |
| 70 | 71 |
| 71 # Enforced on both task request and bots. | 72 # Enforced on both task request and bots. |
| 72 DIMENSION_KEY_RE = ur'^[a-zA-Z\-\_\.]+$' | 73 DIMENSION_KEY_RE = ur'^[a-zA-Z\-\_\.]+$' |
| (...skipping 14 matching lines...) Expand all Loading... |
| 87 # The world started on 2010-01-01 at 00:00:00 UTC. The rationale is that using | 88 # The world started on 2010-01-01 at 00:00:00 UTC. The rationale is that using |
| 88 # EPOCH (1970) means that 40 years worth of keys are wasted. | 89 # EPOCH (1970) means that 40 years worth of keys are wasted. |
| 89 # | 90 # |
| 90 # Note: This creates a 'naive' object instead of a formal UTC object. Note that | 91 # Note: This creates a 'naive' object instead of a formal UTC object. Note that |
| 91 # datetime.datetime.utcnow() also return naive objects. That's python. | 92 # datetime.datetime.utcnow() also return naive objects. That's python. |
| 92 _BEGINING_OF_THE_WORLD = datetime.datetime(2010, 1, 1, 0, 0, 0, 0) | 93 _BEGINING_OF_THE_WORLD = datetime.datetime(2010, 1, 1, 0, 0, 0, 0) |
| 93 | 94 |
| 94 | 95 |
| 95 # Used for isolated files. | 96 # Used for isolated files. |
| 96 _HASH_CHARS = frozenset('0123456789abcdef') | 97 _HASH_CHARS = frozenset('0123456789abcdef') |
| 97 NAMESPACE_RE = re.compile(r'^[a-z0-9A-Z\-._]+$') | |
| 98 | 98 |
| 99 | 99 |
| 100 ### Properties validators must come before the models. | 100 ### Properties validators must come before the models. |
| 101 | 101 |
| 102 | 102 |
| 103 def _validate_isolated(prop, value): | 103 def _validate_isolated(prop, value): |
| 104 if value: | 104 if value: |
| 105 if not _HASH_CHARS.issuperset(value) or len(value) != 40: | 105 if not _HASH_CHARS.issuperset(value) or len(value) != 40: |
| 106 raise datastore_errors.BadValueError( | 106 raise datastore_errors.BadValueError( |
| 107 '%s must be lowercase hex of length 40, not %s' % (prop._name, value)) | 107 '%s must be lowercase hex of length 40, not %s' % (prop._name, value)) |
| 108 | 108 |
| 109 | 109 |
| 110 def _validate_hostname(prop, value): | 110 def _validate_url(prop, value): |
| 111 # pylint: disable=unused-argument | 111 # pylint: disable=unused-argument |
| 112 if value: | 112 if value: |
| 113 # It must be https://*.appspot.com or https?://* | 113 # It must be https://*.appspot.com or https?://* |
| 114 parsed = urlparse.urlparse(value) | 114 parsed = urlparse.urlparse(value) |
| 115 if not parsed.netloc: | 115 if not parsed.netloc: |
| 116 raise datastore_errors.BadValueError( | 116 raise datastore_errors.BadValueError( |
| 117 '%s must be valid hostname, not %s' % (prop._name, value)) | 117 '%s must be valid hostname, not %s' % (prop._name, value)) |
| 118 if parsed.netloc.endswith('appspot.com'): | 118 if parsed.netloc.endswith('appspot.com'): |
| 119 if parsed.scheme != 'https': | 119 if parsed.scheme != 'https': |
| 120 raise datastore_errors.BadValueError( | 120 raise datastore_errors.BadValueError( |
| 121 '%s must be https://, not %s' % (prop._name, value)) | 121 '%s must be https://, not %s' % (prop._name, value)) |
| 122 elif parsed.scheme not in ('http', 'https'): | 122 elif parsed.scheme not in ('http', 'https'): |
| 123 raise datastore_errors.BadValueError( | 123 raise datastore_errors.BadValueError( |
| 124 '%s must be https:// or http://, not %s' % (prop._name, value)) | 124 '%s must be https:// or http://, not %s' % (prop._name, value)) |
| 125 | 125 |
| 126 | 126 |
| 127 def _validate_namespace(prop, value): | 127 def _validate_namespace(prop, value): |
| 128 if not NAMESPACE_RE.match(value): | 128 if not config.NAMESPACE_RE.match(value): |
| 129 raise datastore_errors.BadValueError('malformed %s' % prop._name) | 129 raise datastore_errors.BadValueError('malformed %s' % prop._name) |
| 130 | 130 |
| 131 | 131 |
| 132 def _validate_dict_of_strings(prop, value): | 132 def _validate_dict_of_strings(prop, value): |
| 133 """Validates TaskProperties.env.""" | 133 """Validates TaskProperties.env.""" |
| 134 if not all( | 134 if not all( |
| 135 isinstance(k, unicode) and isinstance(v, unicode) | 135 isinstance(k, unicode) and isinstance(v, unicode) |
| 136 for k, v in value.iteritems()): | 136 for k, v in value.iteritems()): |
| 137 # pylint: disable=W0212 | 137 # pylint: disable=W0212 |
| 138 raise TypeError('%s must be a dict of strings' % prop._name) | 138 raise TypeError('%s must be a dict of strings' % prop._name) |
| (...skipping 58 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 197 | 197 |
| 198 | 198 |
| 199 def _validate_tags(prop, value): | 199 def _validate_tags(prop, value): |
| 200 """Validates and sorts TaskRequest.tags.""" | 200 """Validates and sorts TaskRequest.tags.""" |
| 201 if not ':' in value: | 201 if not ':' in value: |
| 202 # pylint: disable=W0212 | 202 # pylint: disable=W0212 |
| 203 raise datastore_errors.BadValueError( | 203 raise datastore_errors.BadValueError( |
| 204 '%s must be key:value form, not %s' % (prop._name, value)) | 204 '%s must be key:value form, not %s' % (prop._name, value)) |
| 205 | 205 |
| 206 | 206 |
| 207 def _validate_package_name(prop, value): | 207 def _validate_package_name_template(prop, value): |
| 208 """Validates a CIPD package name.""" | 208 """Validates a CIPD package name template.""" |
| 209 if not cipd.is_valid_package_name(value): | 209 if not cipd.is_valid_package_name_template(value): |
| 210 raise datastore_errors.BadValueError( | 210 raise datastore_errors.BadValueError( |
| 211 '%s must be a valid CIPD package name "%s"' % (prop._name, value)) | 211 '%s must be a valid CIPD package name template "%s"' % ( |
| 212 prop._name, value)) |
| 212 | 213 |
| 213 | 214 |
| 214 def _validate_package_version(prop, value): | 215 def _validate_package_version(prop, value): |
| 215 """Validates a CIPD package version.""" | 216 """Validates a CIPD package version.""" |
| 216 if not cipd.is_valid_version(value): | 217 if not cipd.is_valid_version(value): |
| 217 raise datastore_errors.BadValueError( | 218 raise datastore_errors.BadValueError( |
| 218 '%s must be a valid package version "%s"' % (prop._name, value)) | 219 '%s must be a valid package version "%s"' % (prop._name, value)) |
| 219 | 220 |
| 220 | 221 |
| 221 ### Models. | 222 ### Models. |
| 222 | 223 |
| 223 | 224 |
| 224 class FilesRef(ndb.Model): | 225 class FilesRef(ndb.Model): |
| 225 """Defines a data tree reference, normally a reference to a .isolated file.""" | 226 """Defines a data tree reference, normally a reference to a .isolated file.""" |
| 226 | 227 |
| 227 # Specifies FilesRef mode. | 228 # Specifies FilesRef mode. |
| 228 # if True, this instance is a reference to a tree, as class docstring says. | 229 # if True, this instance is a reference to a tree, as class docstring says. |
| 229 # if False, this instance is isolate input/output settings in TaskProperties. | 230 # if False, this instance is isolate input/output settings in TaskProperties. |
| 230 # TODO(maruel): refactor this class, get rid of this. | 231 # TODO(maruel): refactor this class, get rid of this. |
| 231 is_ref = True | 232 is_ref = True |
| 232 | 233 |
| 233 # if is_ref, the hash of an isolated archive. | 234 # if is_ref, the hash of an isolated archive. |
| 234 # otherwise, the hash of the input isolated archive. | 235 # otherwise, the hash of the input isolated archive. |
| 235 isolated = ndb.StringProperty(validator=_validate_isolated, indexed=False) | 236 isolated = ndb.StringProperty(validator=_validate_isolated, indexed=False) |
| 236 # The hostname of the isolated server to use. | 237 # The hostname of the isolated server to use. |
| 237 isolatedserver = ndb.StringProperty( | 238 isolatedserver = ndb.StringProperty( |
| 238 validator=_validate_hostname, indexed=False) | 239 validator=_validate_url, indexed=False) |
| 239 # Namespace on the isolate server. | 240 # Namespace on the isolate server. |
| 240 namespace = ndb.StringProperty(validator=_validate_namespace, indexed=False) | 241 namespace = ndb.StringProperty(validator=_validate_namespace, indexed=False) |
| 241 | 242 |
| 242 def _pre_put_hook(self): | 243 def _pre_put_hook(self): |
| 243 super(FilesRef, self)._pre_put_hook() | 244 super(FilesRef, self)._pre_put_hook() |
| 244 if self.is_ref: | 245 if self.is_ref: |
| 245 if not self.isolated or not self.isolatedserver or not self.namespace: | 246 if not self.isolated or not self.isolatedserver or not self.namespace: |
| 246 raise datastore_errors.BadValueError( | 247 raise datastore_errors.BadValueError( |
| 247 'isolated requires server and namespace') | 248 'isolated requires server and namespace') |
| 248 else: | 249 else: |
| 249 if not self.isolatedserver or not self.namespace: | 250 if not self.isolatedserver or not self.namespace: |
| 250 raise datastore_errors.BadValueError( | 251 raise datastore_errors.BadValueError( |
| 251 'isolate server and namespace are required') | 252 'isolate server and namespace are required') |
| 252 | 253 |
| 253 | 254 |
| 254 class CipdPackage(ndb.Model): | 255 class CipdPackage(ndb.Model): |
| 255 """A CIPD package to install in $CIPD_PATH and $PATH before task execution. | 256 """A CIPD package to install in $CIPD_PATH and $PATH before task execution. |
| 256 | 257 |
| 257 A part of TaskProperties. | 258 A part of TaskProperties. |
| 258 """ | 259 """ |
| 260 # Package name template. May use cipd.ALL_PARAMS. |
| 261 # Most users will specify ${platform} parameter. |
| 259 package_name = ndb.StringProperty( | 262 package_name = ndb.StringProperty( |
| 260 indexed=False, validator=_validate_package_name) | 263 indexed=False, validator=_validate_package_name_template) |
| 264 # Package version that is valid for all packages matched by package_name. |
| 265 # Most users will specify tags. |
| 261 version = ndb.StringProperty( | 266 version = ndb.StringProperty( |
| 262 indexed=False, validator=_validate_package_version) | 267 indexed=False, validator=_validate_package_version) |
| 263 | 268 |
| 264 def _pre_put_hook(self): | 269 def _pre_put_hook(self): |
| 265 super(CipdPackage, self)._pre_put_hook() | 270 super(CipdPackage, self)._pre_put_hook() |
| 266 if not self.package_name: | 271 if not self.package_name: |
| 267 raise datastore_errors.BadValueError('CIPD package name is required') | 272 raise datastore_errors.BadValueError('CIPD package name is required') |
| 268 if not self.version: | 273 if not self.version: |
| 269 raise datastore_errors.BadValueError('CIPD package version is required') | 274 raise datastore_errors.BadValueError('CIPD package version is required') |
| 270 | 275 |
| 271 | 276 |
| 277 class CipdInput(ndb.Model): |
| 278 """Specifies which CIPD client and packages to install, from which server. |
| 279 |
| 280 A part of TaskProperties. |
| 281 """ |
| 282 # URL of the CIPD server. Must start with "https://" or "http://". |
| 283 server = ndb.StringProperty(indexed=False, validator=_validate_url) |
| 284 |
| 285 # CIPD package of CIPD client to use. |
| 286 # client_package.version is required. |
| 287 client_package = ndb.LocalStructuredProperty(CipdPackage) |
| 288 |
| 289 # List of packages to install in $CIPD_PATH prior task execution. |
| 290 packages = ndb.LocalStructuredProperty(CipdPackage, repeated=True) |
| 291 |
| 292 def _pre_put_hook(self): |
| 293 if not self.server: |
| 294 raise datastore_errors.BadValueError('cipd server is required') |
| 295 if not self.client_package: |
| 296 raise datastore_errors.BadValueError('client_package is required') |
| 297 self.client_package._pre_put_hook() |
| 298 |
| 299 if not self.packages: |
| 300 raise datastore_errors.BadValueError( |
| 301 'cipd_input cannot have an empty package list') |
| 302 |
| 303 package_names = set() |
| 304 for p in self.packages: |
| 305 p._pre_put_hook() |
| 306 if p.package_name in package_names: |
| 307 raise datastore_errors.BadValueError( |
| 308 'package %s is specified more than once' % p.package_name) |
| 309 package_names.add(p.package_name) |
| 310 self.packages.sort(key=lambda p: p.package_name) |
| 311 |
| 312 |
| 272 class TaskProperties(ndb.Model): | 313 class TaskProperties(ndb.Model): |
| 273 """Defines all the properties of a task to be run on the Swarming | 314 """Defines all the properties of a task to be run on the Swarming |
| 274 infrastructure. | 315 infrastructure. |
| 275 | 316 |
| 276 This entity is not saved in the DB as a standalone entity, instead it is | 317 This entity is not saved in the DB as a standalone entity, instead it is |
| 277 embedded in a TaskRequest. | 318 embedded in a TaskRequest. |
| 278 | 319 |
| 279 This model is immutable. | 320 This model is immutable. |
| 280 | 321 |
| 281 New-style TaskProperties supports invocation of run_isolated. When this | 322 New-style TaskProperties supports invocation of run_isolated. When this |
| (...skipping 21 matching lines...) Expand all Loading... |
| 303 | 344 |
| 304 # Isolate server, namespace and input isolate hash. | 345 # Isolate server, namespace and input isolate hash. |
| 305 # | 346 # |
| 306 # Despite its name, contains isolate server URL and namespace for isolated | 347 # Despite its name, contains isolate server URL and namespace for isolated |
| 307 # output too. See TODO at the top of this class. | 348 # output too. See TODO at the top of this class. |
| 308 # May be non-None even if task input is not isolated. | 349 # May be non-None even if task input is not isolated. |
| 309 # | 350 # |
| 310 # Only inputs_ref.isolated or command&data can be specified. | 351 # Only inputs_ref.isolated or command&data can be specified. |
| 311 inputs_ref = ndb.LocalStructuredProperty(FilesRef) | 352 inputs_ref = ndb.LocalStructuredProperty(FilesRef) |
| 312 | 353 |
| 313 # A list of CIPD packages to install $CIPD_PATH and $PATH before task | 354 # CIPD packages to install. |
| 314 # execution. | 355 cipd_input = ndb.LocalStructuredProperty(CipdInput) |
| 315 packages = ndb.LocalStructuredProperty(CipdPackage, repeated=True) | |
| 316 | 356 |
| 317 # Filter to use to determine the required properties on the bot to run on. For | 357 # Filter to use to determine the required properties on the bot to run on. For |
| 318 # example, Windows or hostname. Encoded as json. Optional but highly | 358 # example, Windows or hostname. Encoded as json. Optional but highly |
| 319 # recommended. | 359 # recommended. |
| 320 dimensions = datastore_utils.DeterministicJsonProperty( | 360 dimensions = datastore_utils.DeterministicJsonProperty( |
| 321 validator=_validate_dimensions, json_type=dict, indexed=False) | 361 validator=_validate_dimensions, json_type=dict, indexed=False) |
| 322 | 362 |
| 323 # Environment variables. Encoded as json. Optional. | 363 # Environment variables. Encoded as json. Optional. |
| 324 env = datastore_utils.DeterministicJsonProperty( | 364 env = datastore_utils.DeterministicJsonProperty( |
| 325 validator=_validate_dict_of_strings, json_type=dict, indexed=False) | 365 validator=_validate_dict_of_strings, json_type=dict, indexed=False) |
| (...skipping 67 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 393 if bool(self.command) == bool(isolated_input): | 433 if bool(self.command) == bool(isolated_input): |
| 394 raise datastore_errors.BadValueError( | 434 raise datastore_errors.BadValueError( |
| 395 'use one of command or inputs_ref.isolated') | 435 'use one of command or inputs_ref.isolated') |
| 396 if self.extra_args and not isolated_input: | 436 if self.extra_args and not isolated_input: |
| 397 raise datastore_errors.BadValueError( | 437 raise datastore_errors.BadValueError( |
| 398 'extra_args require inputs_ref.isolated') | 438 'extra_args require inputs_ref.isolated') |
| 399 if self.inputs_ref: | 439 if self.inputs_ref: |
| 400 self.inputs_ref.is_ref = False | 440 self.inputs_ref.is_ref = False |
| 401 self.inputs_ref._pre_put_hook() | 441 self.inputs_ref._pre_put_hook() |
| 402 | 442 |
| 403 package_names = set() | 443 if self.cipd_input: |
| 404 for p in self.packages: | 444 self.cipd_input._pre_put_hook() |
| 405 p._pre_put_hook() | 445 if self.idempotent: |
| 406 if p.package_name in package_names: | 446 pinned = lambda p: cipd.is_pinned_version(p.version) |
| 407 raise datastore_errors.BadValueError( | 447 assert self.cipd_input.packages # checked by cipd_input._pre_put_hook |
| 408 'package %s is specified more than once' % p.package_name) | 448 if any(not pinned(p) for p in self.cipd_input.packages): |
| 409 package_names.add(p.package_name) | 449 raise datastore_errors.BadValueError( |
| 410 self.packages.sort(key=lambda p: p.package_name) | 450 'an idempotent task cannot have unpinned packages; ' |
| 411 | 451 'use tags or instance IDs as package versions') |
| 412 if self.idempotent: | |
| 413 pinned = lambda p: cipd.is_pinned_version(p.version) | |
| 414 if self.packages and any(not pinned(p) for p in self.packages): | |
| 415 raise datastore_errors.BadValueError( | |
| 416 'an idempotent task cannot have unpinned packages; ' | |
| 417 'use instance IDs or tags as package versions') | |
| 418 | |
| 419 | 452 |
| 420 class TaskRequest(ndb.Model): | 453 class TaskRequest(ndb.Model): |
| 421 """Contains a user request. | 454 """Contains a user request. |
| 422 | 455 |
| 423 Key id is a decreasing integer based on time since utils.EPOCH plus some | 456 Key id is a decreasing integer based on time since utils.EPOCH plus some |
| 424 randomness on lower order bits. See _new_request_key() for the complete gory | 457 randomness on lower order bits. See _new_request_key() for the complete gory |
| 425 details. | 458 details. |
| 426 | 459 |
| 427 There is also "old style keys" which inherit from a fake root entity | 460 There is also "old style keys" which inherit from a fake root entity |
| 428 TaskRequestShard. | 461 TaskRequestShard. |
| (...skipping 302 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... |
| 731 _put_request(request) | 764 _put_request(request) |
| 732 return request | 765 return request |
| 733 | 766 |
| 734 | 767 |
| 735 def validate_priority(priority): | 768 def validate_priority(priority): |
| 736 """Throws ValueError if priority is not a valid value.""" | 769 """Throws ValueError if priority is not a valid value.""" |
| 737 if 0 > priority or MAXIMUM_PRIORITY < priority: | 770 if 0 > priority or MAXIMUM_PRIORITY < priority: |
| 738 raise datastore_errors.BadValueError( | 771 raise datastore_errors.BadValueError( |
| 739 'priority (%d) must be between 0 and %d (inclusive)' % | 772 'priority (%d) must be between 0 and %d (inclusive)' % |
| 740 (priority, MAXIMUM_PRIORITY)) | 773 (priority, MAXIMUM_PRIORITY)) |
| OLD | NEW |