1# pylint: disable-msg=C0111 2 3import logging 4from datetime import datetime 5import django.core 6try: 7 from django.db import models as dbmodels, connection 8except django.core.exceptions.ImproperlyConfigured: 9 raise ImportError('Django database not yet configured. Import either ' 10 'setup_django_environment or ' 11 'setup_django_lite_environment from ' 12 'autotest_lib.frontend before any imports that ' 13 'depend on django models.') 14from xml.sax import saxutils 15import common 16from autotest_lib.frontend.afe import model_logic, model_attributes 17from autotest_lib.frontend.afe import rdb_model_extensions 18from autotest_lib.frontend import settings, thread_local 19from autotest_lib.client.common_lib import enum, error, host_protections 20from autotest_lib.client.common_lib import global_config 21from autotest_lib.client.common_lib import host_queue_entry_states 22from autotest_lib.client.common_lib import control_data, priorities, decorators 23from autotest_lib.client.common_lib import site_utils 24from autotest_lib.client.common_lib.cros.graphite import autotest_es 25from autotest_lib.server import utils as server_utils 26 27# job options and user preferences 28DEFAULT_REBOOT_BEFORE = model_attributes.RebootBefore.IF_DIRTY 29DEFAULT_REBOOT_AFTER = model_attributes.RebootBefore.NEVER 30 31 32class AclAccessViolation(Exception): 33 """\ 34 Raised when an operation is attempted with proper permissions as 35 dictated by ACLs. 36 """ 37 38 39class AtomicGroup(model_logic.ModelWithInvalid, dbmodels.Model): 40 """\ 41 An atomic group defines a collection of hosts which must only be scheduled 42 all at once. Any host with a label having an atomic group will only be 43 scheduled for a job at the same time as other hosts sharing that label. 44 45 Required: 46 name: A name for this atomic group, e.g. 'rack23' or 'funky_net'. 47 max_number_of_machines: The maximum number of machines that will be 48 scheduled at once when scheduling jobs to this atomic group. 49 The job.synch_count is considered the minimum. 50 51 Optional: 52 description: Arbitrary text description of this group's purpose. 53 """ 54 name = dbmodels.CharField(max_length=255, unique=True) 55 description = dbmodels.TextField(blank=True) 56 # This magic value is the default to simplify the scheduler logic. 57 # It must be "large". The common use of atomic groups is to want all 58 # machines in the group to be used, limits on which subset used are 59 # often chosen via dependency labels. 60 # TODO(dennisjeffrey): Revisit this so we don't have to assume that 61 # "infinity" is around 3.3 million. 62 INFINITE_MACHINES = 333333333 63 max_number_of_machines = dbmodels.IntegerField(default=INFINITE_MACHINES) 64 invalid = dbmodels.BooleanField(default=False, 65 editable=settings.FULL_ADMIN) 66 67 name_field = 'name' 68 objects = model_logic.ModelWithInvalidManager() 69 valid_objects = model_logic.ValidObjectsManager() 70 71 72 def enqueue_job(self, job, is_template=False): 73 """Enqueue a job on an associated atomic group of hosts. 74 75 @param job: A job to enqueue. 76 @param is_template: Whether the status should be "Template". 77 """ 78 queue_entry = HostQueueEntry.create(atomic_group=self, job=job, 79 is_template=is_template) 80 queue_entry.save() 81 82 83 def clean_object(self): 84 self.label_set.clear() 85 86 87 class Meta: 88 """Metadata for class AtomicGroup.""" 89 db_table = 'afe_atomic_groups' 90 91 92 def __unicode__(self): 93 return unicode(self.name) 94 95 96class Label(model_logic.ModelWithInvalid, dbmodels.Model): 97 """\ 98 Required: 99 name: label name 100 101 Optional: 102 kernel_config: URL/path to kernel config for jobs run on this label. 103 platform: If True, this is a platform label (defaults to False). 104 only_if_needed: If True, a Host with this label can only be used if that 105 label is requested by the job/test (either as the meta_host or 106 in the job_dependencies). 107 atomic_group: The atomic group associated with this label. 108 """ 109 name = dbmodels.CharField(max_length=255, unique=True) 110 kernel_config = dbmodels.CharField(max_length=255, blank=True) 111 platform = dbmodels.BooleanField(default=False) 112 invalid = dbmodels.BooleanField(default=False, 113 editable=settings.FULL_ADMIN) 114 only_if_needed = dbmodels.BooleanField(default=False) 115 116 name_field = 'name' 117 objects = model_logic.ModelWithInvalidManager() 118 valid_objects = model_logic.ValidObjectsManager() 119 atomic_group = dbmodels.ForeignKey(AtomicGroup, null=True, blank=True) 120 121 122 def clean_object(self): 123 self.host_set.clear() 124 self.test_set.clear() 125 126 127 def enqueue_job(self, job, atomic_group=None, is_template=False): 128 """Enqueue a job on any host of this label. 129 130 @param job: A job to enqueue. 131 @param atomic_group: The associated atomic group. 132 @param is_template: Whether the status should be "Template". 133 """ 134 queue_entry = HostQueueEntry.create(meta_host=self, job=job, 135 is_template=is_template, 136 atomic_group=atomic_group) 137 queue_entry.save() 138 139 140 141 class Meta: 142 """Metadata for class Label.""" 143 db_table = 'afe_labels' 144 145 146 def __unicode__(self): 147 return unicode(self.name) 148 149 150class Shard(dbmodels.Model, model_logic.ModelExtensions): 151 152 hostname = dbmodels.CharField(max_length=255, unique=True) 153 154 name_field = 'hostname' 155 156 labels = dbmodels.ManyToManyField(Label, blank=True, 157 db_table='afe_shards_labels') 158 159 class Meta: 160 """Metadata for class ParameterizedJob.""" 161 db_table = 'afe_shards' 162 163 164 def rpc_hostname(self): 165 """Get the rpc hostname of the shard. 166 167 @return: Just the shard hostname for all non-testing environments. 168 The address of the default gateway for vm testing environments. 169 """ 170 # TODO: Figure out a better solution for testing. Since no 2 shards 171 # can run on the same host, if the shard hostname is localhost we 172 # conclude that it must be a vm in a test cluster. In such situations 173 # a name of localhost:<port> is necessary to achieve the correct 174 # afe links/redirection from the frontend (this happens through the 175 # host), but for rpcs that are performed *on* the shard, they need to 176 # use the address of the gateway. 177 # In the virtual machine testing environment (i.e., puppylab), each 178 # shard VM has a hostname like localhost:<port>. In the real cluster 179 # environment, a shard node does not have 'localhost' for its hostname. 180 # The following hostname substitution is needed only for the VM 181 # in puppylab. 182 # The 'hostname' should not be replaced in the case of real cluster. 183 if site_utils.is_puppylab_vm(self.hostname): 184 hostname = self.hostname.split(':')[0] 185 return self.hostname.replace( 186 hostname, site_utils.DEFAULT_VM_GATEWAY) 187 return self.hostname 188 189 190class Drone(dbmodels.Model, model_logic.ModelExtensions): 191 """ 192 A scheduler drone 193 194 hostname: the drone's hostname 195 """ 196 hostname = dbmodels.CharField(max_length=255, unique=True) 197 198 name_field = 'hostname' 199 objects = model_logic.ExtendedManager() 200 201 202 def save(self, *args, **kwargs): 203 if not User.current_user().is_superuser(): 204 raise Exception('Only superusers may edit drones') 205 super(Drone, self).save(*args, **kwargs) 206 207 208 def delete(self): 209 if not User.current_user().is_superuser(): 210 raise Exception('Only superusers may delete drones') 211 super(Drone, self).delete() 212 213 214 class Meta: 215 """Metadata for class Drone.""" 216 db_table = 'afe_drones' 217 218 def __unicode__(self): 219 return unicode(self.hostname) 220 221 222class DroneSet(dbmodels.Model, model_logic.ModelExtensions): 223 """ 224 A set of scheduler drones 225 226 These will be used by the scheduler to decide what drones a job is allowed 227 to run on. 228 229 name: the drone set's name 230 drones: the drones that are part of the set 231 """ 232 DRONE_SETS_ENABLED = global_config.global_config.get_config_value( 233 'SCHEDULER', 'drone_sets_enabled', type=bool, default=False) 234 DEFAULT_DRONE_SET_NAME = global_config.global_config.get_config_value( 235 'SCHEDULER', 'default_drone_set_name', default=None) 236 237 name = dbmodels.CharField(max_length=255, unique=True) 238 drones = dbmodels.ManyToManyField(Drone, db_table='afe_drone_sets_drones') 239 240 name_field = 'name' 241 objects = model_logic.ExtendedManager() 242 243 244 def save(self, *args, **kwargs): 245 if not User.current_user().is_superuser(): 246 raise Exception('Only superusers may edit drone sets') 247 super(DroneSet, self).save(*args, **kwargs) 248 249 250 def delete(self): 251 if not User.current_user().is_superuser(): 252 raise Exception('Only superusers may delete drone sets') 253 super(DroneSet, self).delete() 254 255 256 @classmethod 257 def drone_sets_enabled(cls): 258 """Returns whether drone sets are enabled. 259 260 @param cls: Implicit class object. 261 """ 262 return cls.DRONE_SETS_ENABLED 263 264 265 @classmethod 266 def default_drone_set_name(cls): 267 """Returns the default drone set name. 268 269 @param cls: Implicit class object. 270 """ 271 return cls.DEFAULT_DRONE_SET_NAME 272 273 274 @classmethod 275 def get_default(cls): 276 """Gets the default drone set name, compatible with Job.add_object. 277 278 @param cls: Implicit class object. 279 """ 280 return cls.smart_get(cls.DEFAULT_DRONE_SET_NAME) 281 282 283 @classmethod 284 def resolve_name(cls, drone_set_name): 285 """ 286 Returns the name of one of these, if not None, in order of preference: 287 1) the drone set given, 288 2) the current user's default drone set, or 289 3) the global default drone set 290 291 or returns None if drone sets are disabled 292 293 @param cls: Implicit class object. 294 @param drone_set_name: A drone set name. 295 """ 296 if not cls.drone_sets_enabled(): 297 return None 298 299 user = User.current_user() 300 user_drone_set_name = user.drone_set and user.drone_set.name 301 302 return drone_set_name or user_drone_set_name or cls.get_default().name 303 304 305 def get_drone_hostnames(self): 306 """ 307 Gets the hostnames of all drones in this drone set 308 """ 309 return set(self.drones.all().values_list('hostname', flat=True)) 310 311 312 class Meta: 313 """Metadata for class DroneSet.""" 314 db_table = 'afe_drone_sets' 315 316 def __unicode__(self): 317 return unicode(self.name) 318 319 320class User(dbmodels.Model, model_logic.ModelExtensions): 321 """\ 322 Required: 323 login :user login name 324 325 Optional: 326 access_level: 0=User (default), 1=Admin, 100=Root 327 """ 328 ACCESS_ROOT = 100 329 ACCESS_ADMIN = 1 330 ACCESS_USER = 0 331 332 AUTOTEST_SYSTEM = 'autotest_system' 333 334 login = dbmodels.CharField(max_length=255, unique=True) 335 access_level = dbmodels.IntegerField(default=ACCESS_USER, blank=True) 336 337 # user preferences 338 reboot_before = dbmodels.SmallIntegerField( 339 choices=model_attributes.RebootBefore.choices(), blank=True, 340 default=DEFAULT_REBOOT_BEFORE) 341 reboot_after = dbmodels.SmallIntegerField( 342 choices=model_attributes.RebootAfter.choices(), blank=True, 343 default=DEFAULT_REBOOT_AFTER) 344 drone_set = dbmodels.ForeignKey(DroneSet, null=True, blank=True) 345 show_experimental = dbmodels.BooleanField(default=False) 346 347 name_field = 'login' 348 objects = model_logic.ExtendedManager() 349 350 351 def save(self, *args, **kwargs): 352 # is this a new object being saved for the first time? 353 first_time = (self.id is None) 354 user = thread_local.get_user() 355 if user and not user.is_superuser() and user.login != self.login: 356 raise AclAccessViolation("You cannot modify user " + self.login) 357 super(User, self).save(*args, **kwargs) 358 if first_time: 359 everyone = AclGroup.objects.get(name='Everyone') 360 everyone.users.add(self) 361 362 363 def is_superuser(self): 364 """Returns whether the user has superuser access.""" 365 return self.access_level >= self.ACCESS_ROOT 366 367 368 @classmethod 369 def current_user(cls): 370 """Returns the current user. 371 372 @param cls: Implicit class object. 373 """ 374 user = thread_local.get_user() 375 if user is None: 376 user, _ = cls.objects.get_or_create(login=cls.AUTOTEST_SYSTEM) 377 user.access_level = cls.ACCESS_ROOT 378 user.save() 379 return user 380 381 382 @classmethod 383 def get_record(cls, data): 384 """Check the database for an identical record. 385 386 Check for a record with matching id and login. If one exists, 387 return it. If one does not exist there is a possibility that 388 the following cases have happened: 389 1. Same id, different login 390 We received: "1 chromeos-test" 391 And we have: "1 debug-user" 392 In this case we need to delete "1 debug_user" and insert 393 "1 chromeos-test". 394 395 2. Same login, different id: 396 We received: "1 chromeos-test" 397 And we have: "2 chromeos-test" 398 In this case we need to delete "2 chromeos-test" and insert 399 "1 chromeos-test". 400 401 As long as this method deletes bad records and raises the 402 DoesNotExist exception the caller will handle creating the 403 new record. 404 405 @raises: DoesNotExist, if a record with the matching login and id 406 does not exist. 407 """ 408 409 # Both the id and login should be uniqe but there are cases when 410 # we might already have a user with the same login/id because 411 # current_user will proactively create a user record if it doesn't 412 # exist. Since we want to avoid conflict between the master and 413 # shard, just delete any existing user records that don't match 414 # what we're about to deserialize from the master. 415 try: 416 return cls.objects.get(login=data['login'], id=data['id']) 417 except cls.DoesNotExist: 418 cls.delete_matching_record(login=data['login']) 419 cls.delete_matching_record(id=data['id']) 420 raise 421 422 423 class Meta: 424 """Metadata for class User.""" 425 db_table = 'afe_users' 426 427 def __unicode__(self): 428 return unicode(self.login) 429 430 431class Host(model_logic.ModelWithInvalid, rdb_model_extensions.AbstractHostModel, 432 model_logic.ModelWithAttributes): 433 """\ 434 Required: 435 hostname 436 437 optional: 438 locked: if true, host is locked and will not be queued 439 440 Internal: 441 From AbstractHostModel: 442 synch_id: currently unused 443 status: string describing status of host 444 invalid: true if the host has been deleted 445 protection: indicates what can be done to this host during repair 446 lock_time: DateTime at which the host was locked 447 dirty: true if the host has been used without being rebooted 448 Local: 449 locked_by: user that locked the host, or null if the host is unlocked 450 """ 451 452 SERIALIZATION_LINKS_TO_FOLLOW = set(['aclgroup_set', 453 'hostattribute_set', 454 'labels', 455 'shard']) 456 SERIALIZATION_LOCAL_LINKS_TO_UPDATE = set(['invalid']) 457 458 459 def custom_deserialize_relation(self, link, data): 460 assert link == 'shard', 'Link %s should not be deserialized' % link 461 self.shard = Shard.deserialize(data) 462 463 464 # Note: Only specify foreign keys here, specify all native host columns in 465 # rdb_model_extensions instead. 466 Protection = host_protections.Protection 467 labels = dbmodels.ManyToManyField(Label, blank=True, 468 db_table='afe_hosts_labels') 469 locked_by = dbmodels.ForeignKey(User, null=True, blank=True, editable=False) 470 name_field = 'hostname' 471 objects = model_logic.ModelWithInvalidManager() 472 valid_objects = model_logic.ValidObjectsManager() 473 leased_objects = model_logic.LeasedHostManager() 474 475 shard = dbmodels.ForeignKey(Shard, blank=True, null=True) 476 477 def __init__(self, *args, **kwargs): 478 super(Host, self).__init__(*args, **kwargs) 479 self._record_attributes(['status']) 480 481 482 @staticmethod 483 def create_one_time_host(hostname): 484 """Creates a one-time host. 485 486 @param hostname: The name for the host. 487 """ 488 query = Host.objects.filter(hostname=hostname) 489 if query.count() == 0: 490 host = Host(hostname=hostname, invalid=True) 491 host.do_validate() 492 else: 493 host = query[0] 494 if not host.invalid: 495 raise model_logic.ValidationError({ 496 'hostname' : '%s already exists in the autotest DB. ' 497 'Select it rather than entering it as a one time ' 498 'host.' % hostname 499 }) 500 host.protection = host_protections.Protection.DO_NOT_REPAIR 501 host.locked = False 502 host.save() 503 host.clean_object() 504 return host 505 506 507 @classmethod 508 def assign_to_shard(cls, shard, known_ids): 509 """Assigns hosts to a shard. 510 511 For all labels that have been assigned to a shard, all hosts that 512 have at least one of the shard's labels are assigned to the shard. 513 Hosts that are assigned to the shard but aren't already present on the 514 shard are returned. 515 516 Board to shard mapping is many-to-one. Many different boards can be 517 hosted in a shard. However, DUTs of a single board cannot be distributed 518 into more than one shard. 519 520 @param shard: The shard object to assign labels/hosts for. 521 @param known_ids: List of all host-ids the shard already knows. 522 This is used to figure out which hosts should be sent 523 to the shard. If shard_ids were used instead, hosts 524 would only be transferred once, even if the client 525 failed persisting them. 526 The number of hosts usually lies in O(100), so the 527 overhead is acceptable. 528 529 @returns the hosts objects that should be sent to the shard. 530 """ 531 532 # Disclaimer: concurrent heartbeats should theoretically not occur in 533 # the current setup. As they may be introduced in the near future, 534 # this comment will be left here. 535 536 # Sending stuff twice is acceptable, but forgetting something isn't. 537 # Detecting duplicates on the client is easy, but here it's harder. The 538 # following options were considered: 539 # - SELECT ... WHERE and then UPDATE ... WHERE: Update might update more 540 # than select returned, as concurrently more hosts might have been 541 # inserted 542 # - UPDATE and then SELECT WHERE shard=shard: select always returns all 543 # hosts for the shard, this is overhead 544 # - SELECT and then UPDATE only selected without requerying afterwards: 545 # returns the old state of the records. 546 host_ids = set(Host.objects.filter( 547 labels__in=shard.labels.all(), 548 leased=False 549 ).exclude( 550 id__in=known_ids, 551 ).values_list('pk', flat=True)) 552 553 if host_ids: 554 Host.objects.filter(pk__in=host_ids).update(shard=shard) 555 return list(Host.objects.filter(pk__in=host_ids).all()) 556 return [] 557 558 def resurrect_object(self, old_object): 559 super(Host, self).resurrect_object(old_object) 560 # invalid hosts can be in use by the scheduler (as one-time hosts), so 561 # don't change the status 562 self.status = old_object.status 563 564 565 def clean_object(self): 566 self.aclgroup_set.clear() 567 self.labels.clear() 568 569 570 def record_state(self, type_str, state, value, other_metadata=None): 571 """Record metadata in elasticsearch. 572 573 @param type_str: sets the _type field in elasticsearch db. 574 @param state: string representing what state we are recording, 575 e.g. 'locked' 576 @param value: value of the state, e.g. True 577 @param other_metadata: Other metadata to store in metaDB. 578 """ 579 metadata = { 580 state: value, 581 'hostname': self.hostname, 582 } 583 if other_metadata: 584 metadata = dict(metadata.items() + other_metadata.items()) 585 autotest_es.post(use_http=True, type_str=type_str, metadata=metadata) 586 587 588 def save(self, *args, **kwargs): 589 # extra spaces in the hostname can be a sneaky source of errors 590 self.hostname = self.hostname.strip() 591 # is this a new object being saved for the first time? 592 first_time = (self.id is None) 593 if not first_time: 594 AclGroup.check_for_acl_violation_hosts([self]) 595 # If locked is changed, send its status and user made the change to 596 # metaDB. Locks are important in host history because if a device is 597 # locked then we don't really care what state it is in. 598 if self.locked and not self.locked_by: 599 self.locked_by = User.current_user() 600 if not self.lock_time: 601 self.lock_time = datetime.now() 602 self.record_state('lock_history', 'locked', self.locked, 603 {'changed_by': self.locked_by.login, 604 'lock_reason': self.lock_reason}) 605 self.dirty = True 606 elif not self.locked and self.locked_by: 607 self.record_state('lock_history', 'locked', self.locked, 608 {'changed_by': self.locked_by.login}) 609 self.locked_by = None 610 self.lock_time = None 611 super(Host, self).save(*args, **kwargs) 612 if first_time: 613 everyone = AclGroup.objects.get(name='Everyone') 614 everyone.hosts.add(self) 615 self._check_for_updated_attributes() 616 617 618 def delete(self): 619 AclGroup.check_for_acl_violation_hosts([self]) 620 for queue_entry in self.hostqueueentry_set.all(): 621 queue_entry.deleted = True 622 queue_entry.abort() 623 super(Host, self).delete() 624 625 626 def on_attribute_changed(self, attribute, old_value): 627 assert attribute == 'status' 628 logging.info(self.hostname + ' -> ' + self.status) 629 630 631 def enqueue_job(self, job, atomic_group=None, is_template=False): 632 """Enqueue a job on this host. 633 634 @param job: A job to enqueue. 635 @param atomic_group: The associated atomic group. 636 @param is_template: Whther the status should be "Template". 637 """ 638 queue_entry = HostQueueEntry.create(host=self, job=job, 639 is_template=is_template, 640 atomic_group=atomic_group) 641 # allow recovery of dead hosts from the frontend 642 if not self.active_queue_entry() and self.is_dead(): 643 self.status = Host.Status.READY 644 self.save() 645 queue_entry.save() 646 647 block = IneligibleHostQueue(job=job, host=self) 648 block.save() 649 650 651 def platform(self): 652 """The platform of the host.""" 653 # TODO(showard): slighly hacky? 654 platforms = self.labels.filter(platform=True) 655 if len(platforms) == 0: 656 return None 657 return platforms[0] 658 platform.short_description = 'Platform' 659 660 661 @classmethod 662 def check_no_platform(cls, hosts): 663 """Verify the specified hosts have no associated platforms. 664 665 @param cls: Implicit class object. 666 @param hosts: The hosts to verify. 667 @raises model_logic.ValidationError if any hosts already have a 668 platform. 669 """ 670 Host.objects.populate_relationships(hosts, Label, 'label_list') 671 errors = [] 672 for host in hosts: 673 platforms = [label.name for label in host.label_list 674 if label.platform] 675 if platforms: 676 # do a join, just in case this host has multiple platforms, 677 # we'll be able to see it 678 errors.append('Host %s already has a platform: %s' % ( 679 host.hostname, ', '.join(platforms))) 680 if errors: 681 raise model_logic.ValidationError({'labels': '; '.join(errors)}) 682 683 684 def is_dead(self): 685 """Returns whether the host is dead (has status repair failed).""" 686 return self.status == Host.Status.REPAIR_FAILED 687 688 689 def active_queue_entry(self): 690 """Returns the active queue entry for this host, or None if none.""" 691 active = list(self.hostqueueentry_set.filter(active=True)) 692 if not active: 693 return None 694 assert len(active) == 1, ('More than one active entry for ' 695 'host ' + self.hostname) 696 return active[0] 697 698 699 def _get_attribute_model_and_args(self, attribute): 700 return HostAttribute, dict(host=self, attribute=attribute) 701 702 703 @classmethod 704 def get_attribute_model(cls): 705 """Return the attribute model. 706 707 Override method in parent class. See ModelExtensions for details. 708 @returns: The attribute model of Host. 709 """ 710 return HostAttribute 711 712 713 class Meta: 714 """Metadata for the Host class.""" 715 db_table = 'afe_hosts' 716 717 718 def __unicode__(self): 719 return unicode(self.hostname) 720 721 722class HostAttribute(dbmodels.Model, model_logic.ModelExtensions): 723 """Arbitrary keyvals associated with hosts.""" 724 725 SERIALIZATION_LINKS_TO_KEEP = set(['host']) 726 SERIALIZATION_LOCAL_LINKS_TO_UPDATE = set(['value']) 727 host = dbmodels.ForeignKey(Host) 728 attribute = dbmodels.CharField(max_length=90) 729 value = dbmodels.CharField(max_length=300) 730 731 objects = model_logic.ExtendedManager() 732 733 class Meta: 734 """Metadata for the HostAttribute class.""" 735 db_table = 'afe_host_attributes' 736 737 738 @classmethod 739 def get_record(cls, data): 740 """Check the database for an identical record. 741 742 Use host_id and attribute to search for a existing record. 743 744 @raises: DoesNotExist, if no record found 745 @raises: MultipleObjectsReturned if multiple records found. 746 """ 747 # TODO(fdeng): We should use host_id and attribute together as 748 # a primary key in the db. 749 return cls.objects.get(host_id=data['host_id'], 750 attribute=data['attribute']) 751 752 753 @classmethod 754 def deserialize(cls, data): 755 """Override deserialize in parent class. 756 757 Do not deserialize id as id is not kept consistent on master and shards. 758 759 @param data: A dictionary of data to deserialize. 760 761 @returns: A HostAttribute object. 762 """ 763 if data: 764 data.pop('id') 765 return super(HostAttribute, cls).deserialize(data) 766 767 768class Test(dbmodels.Model, model_logic.ModelExtensions): 769 """\ 770 Required: 771 author: author name 772 description: description of the test 773 name: test name 774 time: short, medium, long 775 test_class: This describes the class for your the test belongs in. 776 test_category: This describes the category for your tests 777 test_type: Client or Server 778 path: path to pass to run_test() 779 sync_count: is a number >=1 (1 being the default). If it's 1, then it's an 780 async job. If it's >1 it's sync job for that number of machines 781 i.e. if sync_count = 2 it is a sync job that requires two 782 machines. 783 Optional: 784 dependencies: What the test requires to run. Comma deliminated list 785 dependency_labels: many-to-many relationship with labels corresponding to 786 test dependencies. 787 experimental: If this is set to True production servers will ignore the test 788 run_verify: Whether or not the scheduler should run the verify stage 789 run_reset: Whether or not the scheduler should run the reset stage 790 test_retry: Number of times to retry test if the test did not complete 791 successfully. (optional, default: 0) 792 """ 793 TestTime = enum.Enum('SHORT', 'MEDIUM', 'LONG', start_value=1) 794 795 name = dbmodels.CharField(max_length=255, unique=True) 796 author = dbmodels.CharField(max_length=255) 797 test_class = dbmodels.CharField(max_length=255) 798 test_category = dbmodels.CharField(max_length=255) 799 dependencies = dbmodels.CharField(max_length=255, blank=True) 800 description = dbmodels.TextField(blank=True) 801 experimental = dbmodels.BooleanField(default=True) 802 run_verify = dbmodels.BooleanField(default=False) 803 test_time = dbmodels.SmallIntegerField(choices=TestTime.choices(), 804 default=TestTime.MEDIUM) 805 test_type = dbmodels.SmallIntegerField( 806 choices=control_data.CONTROL_TYPE.choices()) 807 sync_count = dbmodels.IntegerField(default=1) 808 path = dbmodels.CharField(max_length=255, unique=True) 809 test_retry = dbmodels.IntegerField(blank=True, default=0) 810 run_reset = dbmodels.BooleanField(default=True) 811 812 dependency_labels = ( 813 dbmodels.ManyToManyField(Label, blank=True, 814 db_table='afe_autotests_dependency_labels')) 815 name_field = 'name' 816 objects = model_logic.ExtendedManager() 817 818 819 def admin_description(self): 820 """Returns a string representing the admin description.""" 821 escaped_description = saxutils.escape(self.description) 822 return '<span style="white-space:pre">%s</span>' % escaped_description 823 admin_description.allow_tags = True 824 admin_description.short_description = 'Description' 825 826 827 class Meta: 828 """Metadata for class Test.""" 829 db_table = 'afe_autotests' 830 831 def __unicode__(self): 832 return unicode(self.name) 833 834 835class TestParameter(dbmodels.Model): 836 """ 837 A declared parameter of a test 838 """ 839 test = dbmodels.ForeignKey(Test) 840 name = dbmodels.CharField(max_length=255) 841 842 class Meta: 843 """Metadata for class TestParameter.""" 844 db_table = 'afe_test_parameters' 845 unique_together = ('test', 'name') 846 847 def __unicode__(self): 848 return u'%s (%s)' % (self.name, self.test.name) 849 850 851class Profiler(dbmodels.Model, model_logic.ModelExtensions): 852 """\ 853 Required: 854 name: profiler name 855 test_type: Client or Server 856 857 Optional: 858 description: arbirary text description 859 """ 860 name = dbmodels.CharField(max_length=255, unique=True) 861 description = dbmodels.TextField(blank=True) 862 863 name_field = 'name' 864 objects = model_logic.ExtendedManager() 865 866 867 class Meta: 868 """Metadata for class Profiler.""" 869 db_table = 'afe_profilers' 870 871 def __unicode__(self): 872 return unicode(self.name) 873 874 875class AclGroup(dbmodels.Model, model_logic.ModelExtensions): 876 """\ 877 Required: 878 name: name of ACL group 879 880 Optional: 881 description: arbitrary description of group 882 """ 883 884 SERIALIZATION_LINKS_TO_FOLLOW = set(['users']) 885 886 name = dbmodels.CharField(max_length=255, unique=True) 887 description = dbmodels.CharField(max_length=255, blank=True) 888 users = dbmodels.ManyToManyField(User, blank=False, 889 db_table='afe_acl_groups_users') 890 hosts = dbmodels.ManyToManyField(Host, blank=True, 891 db_table='afe_acl_groups_hosts') 892 893 name_field = 'name' 894 objects = model_logic.ExtendedManager() 895 896 @staticmethod 897 def check_for_acl_violation_hosts(hosts): 898 """Verify the current user has access to the specified hosts. 899 900 @param hosts: The hosts to verify against. 901 @raises AclAccessViolation if the current user doesn't have access 902 to a host. 903 """ 904 user = User.current_user() 905 if user.is_superuser(): 906 return 907 accessible_host_ids = set( 908 host.id for host in Host.objects.filter(aclgroup__users=user)) 909 for host in hosts: 910 # Check if the user has access to this host, 911 # but only if it is not a metahost or a one-time-host. 912 no_access = (isinstance(host, Host) 913 and not host.invalid 914 and int(host.id) not in accessible_host_ids) 915 if no_access: 916 raise AclAccessViolation("%s does not have access to %s" % 917 (str(user), str(host))) 918 919 920 @staticmethod 921 def check_abort_permissions(queue_entries): 922 """Look for queue entries that aren't abortable by the current user. 923 924 An entry is not abortable if: 925 * the job isn't owned by this user, and 926 * the machine isn't ACL-accessible, or 927 * the machine is in the "Everyone" ACL 928 929 @param queue_entries: The queue entries to check. 930 @raises AclAccessViolation if a queue entry is not abortable by the 931 current user. 932 """ 933 user = User.current_user() 934 if user.is_superuser(): 935 return 936 not_owned = queue_entries.exclude(job__owner=user.login) 937 # I do this using ID sets instead of just Django filters because 938 # filtering on M2M dbmodels is broken in Django 0.96. It's better in 939 # 1.0. 940 # TODO: Use Django filters, now that we're using 1.0. 941 accessible_ids = set( 942 entry.id for entry 943 in not_owned.filter(host__aclgroup__users__login=user.login)) 944 public_ids = set(entry.id for entry 945 in not_owned.filter(host__aclgroup__name='Everyone')) 946 cannot_abort = [entry for entry in not_owned.select_related() 947 if entry.id not in accessible_ids 948 or entry.id in public_ids] 949 if len(cannot_abort) == 0: 950 return 951 entry_names = ', '.join('%s-%s/%s' % (entry.job.id, entry.job.owner, 952 entry.host_or_metahost_name()) 953 for entry in cannot_abort) 954 raise AclAccessViolation('You cannot abort the following job entries: ' 955 + entry_names) 956 957 958 def check_for_acl_violation_acl_group(self): 959 """Verifies the current user has acces to this ACL group. 960 961 @raises AclAccessViolation if the current user doesn't have access to 962 this ACL group. 963 """ 964 user = User.current_user() 965 if user.is_superuser(): 966 return 967 if self.name == 'Everyone': 968 raise AclAccessViolation("You cannot modify 'Everyone'!") 969 if not user in self.users.all(): 970 raise AclAccessViolation("You do not have access to %s" 971 % self.name) 972 973 @staticmethod 974 def on_host_membership_change(): 975 """Invoked when host membership changes.""" 976 everyone = AclGroup.objects.get(name='Everyone') 977 978 # find hosts that aren't in any ACL group and add them to Everyone 979 # TODO(showard): this is a bit of a hack, since the fact that this query 980 # works is kind of a coincidence of Django internals. This trick 981 # doesn't work in general (on all foreign key relationships). I'll 982 # replace it with a better technique when the need arises. 983 orphaned_hosts = Host.valid_objects.filter(aclgroup__id__isnull=True) 984 everyone.hosts.add(*orphaned_hosts.distinct()) 985 986 # find hosts in both Everyone and another ACL group, and remove them 987 # from Everyone 988 hosts_in_everyone = Host.valid_objects.filter(aclgroup__name='Everyone') 989 acled_hosts = set() 990 for host in hosts_in_everyone: 991 # Has an ACL group other than Everyone 992 if host.aclgroup_set.count() > 1: 993 acled_hosts.add(host) 994 everyone.hosts.remove(*acled_hosts) 995 996 997 def delete(self): 998 if (self.name == 'Everyone'): 999 raise AclAccessViolation("You cannot delete 'Everyone'!") 1000 self.check_for_acl_violation_acl_group() 1001 super(AclGroup, self).delete() 1002 self.on_host_membership_change() 1003 1004 1005 def add_current_user_if_empty(self): 1006 """Adds the current user if the set of users is empty.""" 1007 if not self.users.count(): 1008 self.users.add(User.current_user()) 1009 1010 1011 def perform_after_save(self, change): 1012 """Called after a save. 1013 1014 @param change: Whether there was a change. 1015 """ 1016 if not change: 1017 self.users.add(User.current_user()) 1018 self.add_current_user_if_empty() 1019 self.on_host_membership_change() 1020 1021 1022 def save(self, *args, **kwargs): 1023 change = bool(self.id) 1024 if change: 1025 # Check the original object for an ACL violation 1026 AclGroup.objects.get(id=self.id).check_for_acl_violation_acl_group() 1027 super(AclGroup, self).save(*args, **kwargs) 1028 self.perform_after_save(change) 1029 1030 1031 class Meta: 1032 """Metadata for class AclGroup.""" 1033 db_table = 'afe_acl_groups' 1034 1035 def __unicode__(self): 1036 return unicode(self.name) 1037 1038 1039class Kernel(dbmodels.Model): 1040 """ 1041 A kernel configuration for a parameterized job 1042 """ 1043 version = dbmodels.CharField(max_length=255) 1044 cmdline = dbmodels.CharField(max_length=255, blank=True) 1045 1046 @classmethod 1047 def create_kernels(cls, kernel_list): 1048 """Creates all kernels in the kernel list. 1049 1050 @param cls: Implicit class object. 1051 @param kernel_list: A list of dictionaries that describe the kernels, 1052 in the same format as the 'kernel' argument to 1053 rpc_interface.generate_control_file. 1054 @return A list of the created kernels. 1055 """ 1056 if not kernel_list: 1057 return None 1058 return [cls._create(kernel) for kernel in kernel_list] 1059 1060 1061 @classmethod 1062 def _create(cls, kernel_dict): 1063 version = kernel_dict.pop('version') 1064 cmdline = kernel_dict.pop('cmdline', '') 1065 1066 if kernel_dict: 1067 raise Exception('Extraneous kernel arguments remain: %r' 1068 % kernel_dict) 1069 1070 kernel, _ = cls.objects.get_or_create(version=version, 1071 cmdline=cmdline) 1072 return kernel 1073 1074 1075 class Meta: 1076 """Metadata for class Kernel.""" 1077 db_table = 'afe_kernels' 1078 unique_together = ('version', 'cmdline') 1079 1080 def __unicode__(self): 1081 return u'%s %s' % (self.version, self.cmdline) 1082 1083 1084class ParameterizedJob(dbmodels.Model): 1085 """ 1086 Auxiliary configuration for a parameterized job. 1087 """ 1088 test = dbmodels.ForeignKey(Test) 1089 label = dbmodels.ForeignKey(Label, null=True) 1090 use_container = dbmodels.BooleanField(default=False) 1091 profile_only = dbmodels.BooleanField(default=False) 1092 upload_kernel_config = dbmodels.BooleanField(default=False) 1093 1094 kernels = dbmodels.ManyToManyField( 1095 Kernel, db_table='afe_parameterized_job_kernels') 1096 profilers = dbmodels.ManyToManyField( 1097 Profiler, through='ParameterizedJobProfiler') 1098 1099 1100 @classmethod 1101 def smart_get(cls, id_or_name, *args, **kwargs): 1102 """For compatibility with Job.add_object. 1103 1104 @param cls: Implicit class object. 1105 @param id_or_name: The ID or name to get. 1106 @param args: Non-keyword arguments. 1107 @param kwargs: Keyword arguments. 1108 """ 1109 return cls.objects.get(pk=id_or_name) 1110 1111 1112 def job(self): 1113 """Returns the job if it exists, or else None.""" 1114 jobs = self.job_set.all() 1115 assert jobs.count() <= 1 1116 return jobs and jobs[0] or None 1117 1118 1119 class Meta: 1120 """Metadata for class ParameterizedJob.""" 1121 db_table = 'afe_parameterized_jobs' 1122 1123 def __unicode__(self): 1124 return u'%s (parameterized) - %s' % (self.test.name, self.job()) 1125 1126 1127class ParameterizedJobProfiler(dbmodels.Model): 1128 """ 1129 A profiler to run on a parameterized job 1130 """ 1131 parameterized_job = dbmodels.ForeignKey(ParameterizedJob) 1132 profiler = dbmodels.ForeignKey(Profiler) 1133 1134 class Meta: 1135 """Metedata for class ParameterizedJobProfiler.""" 1136 db_table = 'afe_parameterized_jobs_profilers' 1137 unique_together = ('parameterized_job', 'profiler') 1138 1139 1140class ParameterizedJobProfilerParameter(dbmodels.Model): 1141 """ 1142 A parameter for a profiler in a parameterized job 1143 """ 1144 parameterized_job_profiler = dbmodels.ForeignKey(ParameterizedJobProfiler) 1145 parameter_name = dbmodels.CharField(max_length=255) 1146 parameter_value = dbmodels.TextField() 1147 parameter_type = dbmodels.CharField( 1148 max_length=8, choices=model_attributes.ParameterTypes.choices()) 1149 1150 class Meta: 1151 """Metadata for class ParameterizedJobProfilerParameter.""" 1152 db_table = 'afe_parameterized_job_profiler_parameters' 1153 unique_together = ('parameterized_job_profiler', 'parameter_name') 1154 1155 def __unicode__(self): 1156 return u'%s - %s' % (self.parameterized_job_profiler.profiler.name, 1157 self.parameter_name) 1158 1159 1160class ParameterizedJobParameter(dbmodels.Model): 1161 """ 1162 Parameters for a parameterized job 1163 """ 1164 parameterized_job = dbmodels.ForeignKey(ParameterizedJob) 1165 test_parameter = dbmodels.ForeignKey(TestParameter) 1166 parameter_value = dbmodels.TextField() 1167 parameter_type = dbmodels.CharField( 1168 max_length=8, choices=model_attributes.ParameterTypes.choices()) 1169 1170 class Meta: 1171 """Metadata for class ParameterizedJobParameter.""" 1172 db_table = 'afe_parameterized_job_parameters' 1173 unique_together = ('parameterized_job', 'test_parameter') 1174 1175 def __unicode__(self): 1176 return u'%s - %s' % (self.parameterized_job.job().name, 1177 self.test_parameter.name) 1178 1179 1180class JobManager(model_logic.ExtendedManager): 1181 'Custom manager to provide efficient status counts querying.' 1182 def get_status_counts(self, job_ids): 1183 """Returns a dict mapping the given job IDs to their status count dicts. 1184 1185 @param job_ids: A list of job IDs. 1186 """ 1187 if not job_ids: 1188 return {} 1189 id_list = '(%s)' % ','.join(str(job_id) for job_id in job_ids) 1190 cursor = connection.cursor() 1191 cursor.execute(""" 1192 SELECT job_id, status, aborted, complete, COUNT(*) 1193 FROM afe_host_queue_entries 1194 WHERE job_id IN %s 1195 GROUP BY job_id, status, aborted, complete 1196 """ % id_list) 1197 all_job_counts = dict((job_id, {}) for job_id in job_ids) 1198 for job_id, status, aborted, complete, count in cursor.fetchall(): 1199 job_dict = all_job_counts[job_id] 1200 full_status = HostQueueEntry.compute_full_status(status, aborted, 1201 complete) 1202 job_dict.setdefault(full_status, 0) 1203 job_dict[full_status] += count 1204 return all_job_counts 1205 1206 1207class Job(dbmodels.Model, model_logic.ModelExtensions): 1208 """\ 1209 owner: username of job owner 1210 name: job name (does not have to be unique) 1211 priority: Integer priority value. Higher is more important. 1212 control_file: contents of control file 1213 control_type: Client or Server 1214 created_on: date of job creation 1215 submitted_on: date of job submission 1216 synch_count: how many hosts should be used per autoserv execution 1217 run_verify: Whether or not to run the verify phase 1218 run_reset: Whether or not to run the reset phase 1219 timeout: DEPRECATED - hours from queuing time until job times out 1220 timeout_mins: minutes from job queuing time until the job times out 1221 max_runtime_hrs: DEPRECATED - hours from job starting time until job 1222 times out 1223 max_runtime_mins: minutes from job starting time until job times out 1224 email_list: list of people to email on completion delimited by any of: 1225 white space, ',', ':', ';' 1226 dependency_labels: many-to-many relationship with labels corresponding to 1227 job dependencies 1228 reboot_before: Never, If dirty, or Always 1229 reboot_after: Never, If all tests passed, or Always 1230 parse_failed_repair: if True, a failed repair launched by this job will have 1231 its results parsed as part of the job. 1232 drone_set: The set of drones to run this job on 1233 parent_job: Parent job (optional) 1234 test_retry: Number of times to retry test if the test did not complete 1235 successfully. (optional, default: 0) 1236 require_ssp: Require server-side packaging unless require_ssp is set to 1237 False. (optional, default: None) 1238 """ 1239 1240 # TODO: Investigate, if jobkeyval_set is really needed. 1241 # dynamic_suite will write them into an attached file for the drone, but 1242 # it doesn't seem like they are actually used. If they aren't used, remove 1243 # jobkeyval_set here. 1244 SERIALIZATION_LINKS_TO_FOLLOW = set(['dependency_labels', 1245 'hostqueueentry_set', 1246 'jobkeyval_set', 1247 'shard']) 1248 1249 # SQL for selecting jobs that should be sent to shard. 1250 # We use raw sql as django filters were not optimized. 1251 # The following jobs are excluded by the SQL. 1252 # - Non-aborted jobs known to shard as specified in |known_ids|. 1253 # Note for jobs aborted on master, even if already known to shard, 1254 # will be sent to shard again so that shard can abort them. 1255 # - Completed jobs 1256 # - Active jobs 1257 # - Jobs without host_queue_entries 1258 NON_ABORTED_KNOWN_JOBS = '(t2.aborted = 0 AND t1.id IN (%(known_ids)s))' 1259 1260 SQL_SHARD_JOBS = ( 1261 'SELECT DISTINCT(t1.id) FROM afe_jobs t1 ' 1262 'INNER JOIN afe_host_queue_entries t2 ON ' 1263 ' (t1.id = t2.job_id AND t2.complete != 1 AND t2.active != 1 ' 1264 ' %(check_known_jobs)s) ' 1265 'LEFT OUTER JOIN afe_jobs_dependency_labels t3 ON (t1.id = t3.job_id) ' 1266 'WHERE (t3.label_id IN ' 1267 ' (SELECT label_id FROM afe_shards_labels ' 1268 ' WHERE shard_id = %(shard_id)s) ' 1269 ' OR t2.meta_host IN ' 1270 ' (SELECT label_id FROM afe_shards_labels ' 1271 ' WHERE shard_id = %(shard_id)s))' 1272 ) 1273 1274 # Jobs can be created with assigned hosts and have no dependency 1275 # labels nor meta_host. 1276 # We are looking for: 1277 # - a job whose hqe's meta_host is null 1278 # - a job whose hqe has a host 1279 # - one of the host's labels matches the shard's label. 1280 # Non-aborted known jobs, completed jobs, active jobs, jobs 1281 # without hqe are exluded as we do with SQL_SHARD_JOBS. 1282 SQL_SHARD_JOBS_WITH_HOSTS = ( 1283 'SELECT DISTINCT(t1.id) FROM afe_jobs t1 ' 1284 'INNER JOIN afe_host_queue_entries t2 ON ' 1285 ' (t1.id = t2.job_id AND t2.complete != 1 AND t2.active != 1 ' 1286 ' AND t2.meta_host IS NULL AND t2.host_id IS NOT NULL ' 1287 ' %(check_known_jobs)s) ' 1288 'LEFT OUTER JOIN afe_hosts_labels t3 ON (t2.host_id = t3.host_id) ' 1289 'WHERE (t3.label_id IN ' 1290 ' (SELECT label_id FROM afe_shards_labels ' 1291 ' WHERE shard_id = %(shard_id)s))' 1292 ) 1293 1294 # Even if we had filters about complete, active and aborted 1295 # bits in the above two SQLs, there is a chance that 1296 # the result may still contain a job with an hqe with 'complete=1' 1297 # or 'active=1' or 'aborted=0 and afe_job.id in known jobs.' 1298 # This happens when a job has two (or more) hqes and at least 1299 # one hqe has different bits than others. 1300 # We use a second sql to ensure we exclude all un-desired jobs. 1301 SQL_JOBS_TO_EXCLUDE =( 1302 'SELECT t1.id FROM afe_jobs t1 ' 1303 'INNER JOIN afe_host_queue_entries t2 ON ' 1304 ' (t1.id = t2.job_id) ' 1305 'WHERE (t1.id in (%(candidates)s) ' 1306 ' AND (t2.complete=1 OR t2.active=1 ' 1307 ' %(check_known_jobs)s))' 1308 ) 1309 1310 def _deserialize_relation(self, link, data): 1311 if link in ['hostqueueentry_set', 'jobkeyval_set']: 1312 for obj in data: 1313 obj['job_id'] = self.id 1314 1315 super(Job, self)._deserialize_relation(link, data) 1316 1317 1318 def custom_deserialize_relation(self, link, data): 1319 assert link == 'shard', 'Link %s should not be deserialized' % link 1320 self.shard = Shard.deserialize(data) 1321 1322 1323 def sanity_check_update_from_shard(self, shard, updated_serialized): 1324 # If the job got aborted on the master after the client fetched it 1325 # no shard_id will be set. The shard might still push updates though, 1326 # as the job might complete before the abort bit syncs to the shard. 1327 # Alternative considered: The master scheduler could be changed to not 1328 # set aborted jobs to completed that are sharded out. But that would 1329 # require database queries and seemed more complicated to implement. 1330 # This seems safe to do, as there won't be updates pushed from the wrong 1331 # shards should be powered off and wiped hen they are removed from the 1332 # master. 1333 if self.shard_id and self.shard_id != shard.id: 1334 raise error.UnallowedRecordsSentToMaster( 1335 'Job id=%s is assigned to shard (%s). Cannot update it with %s ' 1336 'from shard %s.' % (self.id, self.shard_id, updated_serialized, 1337 shard.id)) 1338 1339 1340 # TIMEOUT is deprecated. 1341 DEFAULT_TIMEOUT = global_config.global_config.get_config_value( 1342 'AUTOTEST_WEB', 'job_timeout_default', default=24) 1343 DEFAULT_TIMEOUT_MINS = global_config.global_config.get_config_value( 1344 'AUTOTEST_WEB', 'job_timeout_mins_default', default=24*60) 1345 # MAX_RUNTIME_HRS is deprecated. Will be removed after switch to mins is 1346 # completed. 1347 DEFAULT_MAX_RUNTIME_HRS = global_config.global_config.get_config_value( 1348 'AUTOTEST_WEB', 'job_max_runtime_hrs_default', default=72) 1349 DEFAULT_MAX_RUNTIME_MINS = global_config.global_config.get_config_value( 1350 'AUTOTEST_WEB', 'job_max_runtime_mins_default', default=72*60) 1351 DEFAULT_PARSE_FAILED_REPAIR = global_config.global_config.get_config_value( 1352 'AUTOTEST_WEB', 'parse_failed_repair_default', type=bool, 1353 default=False) 1354 1355 owner = dbmodels.CharField(max_length=255) 1356 name = dbmodels.CharField(max_length=255) 1357 priority = dbmodels.SmallIntegerField(default=priorities.Priority.DEFAULT) 1358 control_file = dbmodels.TextField(null=True, blank=True) 1359 control_type = dbmodels.SmallIntegerField( 1360 choices=control_data.CONTROL_TYPE.choices(), 1361 blank=True, # to allow 0 1362 default=control_data.CONTROL_TYPE.CLIENT) 1363 created_on = dbmodels.DateTimeField() 1364 synch_count = dbmodels.IntegerField(blank=True, default=0) 1365 timeout = dbmodels.IntegerField(default=DEFAULT_TIMEOUT) 1366 run_verify = dbmodels.BooleanField(default=False) 1367 email_list = dbmodels.CharField(max_length=250, blank=True) 1368 dependency_labels = ( 1369 dbmodels.ManyToManyField(Label, blank=True, 1370 db_table='afe_jobs_dependency_labels')) 1371 reboot_before = dbmodels.SmallIntegerField( 1372 choices=model_attributes.RebootBefore.choices(), blank=True, 1373 default=DEFAULT_REBOOT_BEFORE) 1374 reboot_after = dbmodels.SmallIntegerField( 1375 choices=model_attributes.RebootAfter.choices(), blank=True, 1376 default=DEFAULT_REBOOT_AFTER) 1377 parse_failed_repair = dbmodels.BooleanField( 1378 default=DEFAULT_PARSE_FAILED_REPAIR) 1379 # max_runtime_hrs is deprecated. Will be removed after switch to mins is 1380 # completed. 1381 max_runtime_hrs = dbmodels.IntegerField(default=DEFAULT_MAX_RUNTIME_HRS) 1382 max_runtime_mins = dbmodels.IntegerField(default=DEFAULT_MAX_RUNTIME_MINS) 1383 drone_set = dbmodels.ForeignKey(DroneSet, null=True, blank=True) 1384 1385 parameterized_job = dbmodels.ForeignKey(ParameterizedJob, null=True, 1386 blank=True) 1387 1388 parent_job = dbmodels.ForeignKey('self', blank=True, null=True) 1389 1390 test_retry = dbmodels.IntegerField(blank=True, default=0) 1391 1392 run_reset = dbmodels.BooleanField(default=True) 1393 1394 timeout_mins = dbmodels.IntegerField(default=DEFAULT_TIMEOUT_MINS) 1395 1396 # If this is None on the master, a slave should be found. 1397 # If this is None on a slave, it should be synced back to the master 1398 shard = dbmodels.ForeignKey(Shard, blank=True, null=True) 1399 1400 # If this is None, server-side packaging will be used for server side test, 1401 # unless it's disabled in global config AUTOSERV/enable_ssp_container. 1402 require_ssp = dbmodels.NullBooleanField(default=None, blank=True, null=True) 1403 1404 # custom manager 1405 objects = JobManager() 1406 1407 1408 @decorators.cached_property 1409 def labels(self): 1410 """All the labels of this job""" 1411 # We need to convert dependency_labels to a list, because all() gives us 1412 # back an iterator, and storing/caching an iterator means we'd only be 1413 # able to read from it once. 1414 return list(self.dependency_labels.all()) 1415 1416 1417 def is_server_job(self): 1418 """Returns whether this job is of type server.""" 1419 return self.control_type == control_data.CONTROL_TYPE.SERVER 1420 1421 1422 @classmethod 1423 def parameterized_jobs_enabled(cls): 1424 """Returns whether parameterized jobs are enabled. 1425 1426 @param cls: Implicit class object. 1427 """ 1428 return global_config.global_config.get_config_value( 1429 'AUTOTEST_WEB', 'parameterized_jobs', type=bool) 1430 1431 1432 @classmethod 1433 def check_parameterized_job(cls, control_file, parameterized_job): 1434 """Checks that the job is valid given the global config settings. 1435 1436 First, either control_file must be set, or parameterized_job must be 1437 set, but not both. Second, parameterized_job must be set if and only if 1438 the parameterized_jobs option in the global config is set to True. 1439 1440 @param cls: Implict class object. 1441 @param control_file: A control file. 1442 @param parameterized_job: A parameterized job. 1443 """ 1444 if not (bool(control_file) ^ bool(parameterized_job)): 1445 raise Exception('Job must have either control file or ' 1446 'parameterization, but not both') 1447 1448 parameterized_jobs_enabled = cls.parameterized_jobs_enabled() 1449 if control_file and parameterized_jobs_enabled: 1450 raise Exception('Control file specified, but parameterized jobs ' 1451 'are enabled') 1452 if parameterized_job and not parameterized_jobs_enabled: 1453 raise Exception('Parameterized job specified, but parameterized ' 1454 'jobs are not enabled') 1455 1456 1457 @classmethod 1458 def create(cls, owner, options, hosts): 1459 """Creates a job. 1460 1461 The job is created by taking some information (the listed args) and 1462 filling in the rest of the necessary information. 1463 1464 @param cls: Implicit class object. 1465 @param owner: The owner for the job. 1466 @param options: An options object. 1467 @param hosts: The hosts to use. 1468 """ 1469 AclGroup.check_for_acl_violation_hosts(hosts) 1470 1471 control_file = options.get('control_file') 1472 parameterized_job = options.get('parameterized_job') 1473 1474 # The current implementation of parameterized jobs requires that only 1475 # control files or parameterized jobs are used. Using the image 1476 # parameter on autoupdate_ParameterizedJob doesn't mix pure 1477 # parameterized jobs and control files jobs, it does muck enough with 1478 # normal jobs by adding a parameterized id to them that this check will 1479 # fail. So for now we just skip this check. 1480 # cls.check_parameterized_job(control_file=control_file, 1481 # parameterized_job=parameterized_job) 1482 user = User.current_user() 1483 if options.get('reboot_before') is None: 1484 options['reboot_before'] = user.get_reboot_before_display() 1485 if options.get('reboot_after') is None: 1486 options['reboot_after'] = user.get_reboot_after_display() 1487 1488 drone_set = DroneSet.resolve_name(options.get('drone_set')) 1489 1490 if options.get('timeout_mins') is None and options.get('timeout'): 1491 options['timeout_mins'] = options['timeout'] * 60 1492 1493 job = cls.add_object( 1494 owner=owner, 1495 name=options['name'], 1496 priority=options['priority'], 1497 control_file=control_file, 1498 control_type=options['control_type'], 1499 synch_count=options.get('synch_count'), 1500 # timeout needs to be deleted in the future. 1501 timeout=options.get('timeout'), 1502 timeout_mins=options.get('timeout_mins'), 1503 max_runtime_mins=options.get('max_runtime_mins'), 1504 run_verify=options.get('run_verify'), 1505 email_list=options.get('email_list'), 1506 reboot_before=options.get('reboot_before'), 1507 reboot_after=options.get('reboot_after'), 1508 parse_failed_repair=options.get('parse_failed_repair'), 1509 created_on=datetime.now(), 1510 drone_set=drone_set, 1511 parameterized_job=parameterized_job, 1512 parent_job=options.get('parent_job_id'), 1513 test_retry=options.get('test_retry'), 1514 run_reset=options.get('run_reset'), 1515 require_ssp=options.get('require_ssp')) 1516 1517 job.dependency_labels = options['dependencies'] 1518 1519 if options.get('keyvals'): 1520 for key, value in options['keyvals'].iteritems(): 1521 JobKeyval.objects.create(job=job, key=key, value=value) 1522 1523 return job 1524 1525 1526 @classmethod 1527 def assign_to_shard(cls, shard, known_ids): 1528 """Assigns unassigned jobs to a shard. 1529 1530 For all labels that have been assigned to this shard, all jobs that 1531 have this label, are assigned to this shard. 1532 1533 Jobs that are assigned to the shard but aren't already present on the 1534 shard are returned. 1535 1536 @param shard: The shard to assign jobs to. 1537 @param known_ids: List of all ids of incomplete jobs, the shard already 1538 knows about. 1539 This is used to figure out which jobs should be sent 1540 to the shard. If shard_ids were used instead, jobs 1541 would only be transferred once, even if the client 1542 failed persisting them. 1543 The number of unfinished jobs usually lies in O(1000). 1544 Assuming one id takes 8 chars in the json, this means 1545 overhead that lies in the lower kilobyte range. 1546 A not in query with 5000 id's takes about 30ms. 1547 1548 @returns The job objects that should be sent to the shard. 1549 """ 1550 # Disclaimer: Concurrent heartbeats should not occur in today's setup. 1551 # If this changes or they are triggered manually, this applies: 1552 # Jobs may be returned more than once by concurrent calls of this 1553 # function, as there is a race condition between SELECT and UPDATE. 1554 job_ids = set([]) 1555 check_known_jobs_exclude = '' 1556 check_known_jobs_include = '' 1557 1558 if known_ids: 1559 check_known_jobs = ( 1560 cls.NON_ABORTED_KNOWN_JOBS % 1561 {'known_ids': ','.join([str(i) for i in known_ids])}) 1562 check_known_jobs_exclude = 'AND NOT ' + check_known_jobs 1563 check_known_jobs_include = 'OR ' + check_known_jobs 1564 1565 for sql in [cls.SQL_SHARD_JOBS, cls.SQL_SHARD_JOBS_WITH_HOSTS]: 1566 query = Job.objects.raw(sql % { 1567 'check_known_jobs': check_known_jobs_exclude, 1568 'shard_id': shard.id}) 1569 job_ids |= set([j.id for j in query]) 1570 1571 if job_ids: 1572 query = Job.objects.raw( 1573 cls.SQL_JOBS_TO_EXCLUDE % 1574 {'check_known_jobs': check_known_jobs_include, 1575 'candidates': ','.join([str(i) for i in job_ids])}) 1576 job_ids -= set([j.id for j in query]) 1577 1578 if job_ids: 1579 Job.objects.filter(pk__in=job_ids).update(shard=shard) 1580 return list(Job.objects.filter(pk__in=job_ids).all()) 1581 return [] 1582 1583 1584 def save(self, *args, **kwargs): 1585 # The current implementation of parameterized jobs requires that only 1586 # control files or parameterized jobs are used. Using the image 1587 # parameter on autoupdate_ParameterizedJob doesn't mix pure 1588 # parameterized jobs and control files jobs, it does muck enough with 1589 # normal jobs by adding a parameterized id to them that this check will 1590 # fail. So for now we just skip this check. 1591 # cls.check_parameterized_job(control_file=self.control_file, 1592 # parameterized_job=self.parameterized_job) 1593 super(Job, self).save(*args, **kwargs) 1594 1595 1596 def queue(self, hosts, atomic_group=None, is_template=False): 1597 """Enqueue a job on the given hosts. 1598 1599 @param hosts: The hosts to use. 1600 @param atomic_group: The associated atomic group. 1601 @param is_template: Whether the status should be "Template". 1602 """ 1603 if not hosts: 1604 if atomic_group: 1605 # No hosts or labels are required to queue an atomic group 1606 # Job. However, if they are given, we respect them below. 1607 atomic_group.enqueue_job(self, is_template=is_template) 1608 else: 1609 # hostless job 1610 entry = HostQueueEntry.create(job=self, is_template=is_template) 1611 entry.save() 1612 return 1613 1614 for host in hosts: 1615 host.enqueue_job(self, atomic_group=atomic_group, 1616 is_template=is_template) 1617 1618 1619 def create_recurring_job(self, start_date, loop_period, loop_count, owner): 1620 """Creates a recurring job. 1621 1622 @param start_date: The starting date of the job. 1623 @param loop_period: How often to re-run the job, in seconds. 1624 @param loop_count: The re-run count. 1625 @param owner: The owner of the job. 1626 """ 1627 rec = RecurringRun(job=self, start_date=start_date, 1628 loop_period=loop_period, 1629 loop_count=loop_count, 1630 owner=User.objects.get(login=owner)) 1631 rec.save() 1632 return rec.id 1633 1634 1635 def user(self): 1636 """Gets the user of this job, or None if it doesn't exist.""" 1637 try: 1638 return User.objects.get(login=self.owner) 1639 except self.DoesNotExist: 1640 return None 1641 1642 1643 def abort(self): 1644 """Aborts this job.""" 1645 for queue_entry in self.hostqueueentry_set.all(): 1646 queue_entry.abort() 1647 1648 1649 def tag(self): 1650 """Returns a string tag for this job.""" 1651 return server_utils.get_job_tag(self.id, self.owner) 1652 1653 1654 def keyval_dict(self): 1655 """Returns all keyvals for this job as a dictionary.""" 1656 return dict((keyval.key, keyval.value) 1657 for keyval in self.jobkeyval_set.all()) 1658 1659 1660 @classmethod 1661 def get_attribute_model(cls): 1662 """Return the attribute model. 1663 1664 Override method in parent class. This class is called when 1665 deserializing the one-to-many relationship betwen Job and JobKeyval. 1666 On deserialization, we will try to clear any existing job keyvals 1667 associated with a job to avoid any inconsistency. 1668 Though Job doesn't implement ModelWithAttribute, we still treat 1669 it as an attribute model for this purpose. 1670 1671 @returns: The attribute model of Job. 1672 """ 1673 return JobKeyval 1674 1675 1676 class Meta: 1677 """Metadata for class Job.""" 1678 db_table = 'afe_jobs' 1679 1680 def __unicode__(self): 1681 return u'%s (%s-%s)' % (self.name, self.id, self.owner) 1682 1683 1684class JobKeyval(dbmodels.Model, model_logic.ModelExtensions): 1685 """Keyvals associated with jobs""" 1686 1687 SERIALIZATION_LINKS_TO_KEEP = set(['job']) 1688 SERIALIZATION_LOCAL_LINKS_TO_UPDATE = set(['value']) 1689 1690 job = dbmodels.ForeignKey(Job) 1691 key = dbmodels.CharField(max_length=90) 1692 value = dbmodels.CharField(max_length=300) 1693 1694 objects = model_logic.ExtendedManager() 1695 1696 1697 @classmethod 1698 def get_record(cls, data): 1699 """Check the database for an identical record. 1700 1701 Use job_id and key to search for a existing record. 1702 1703 @raises: DoesNotExist, if no record found 1704 @raises: MultipleObjectsReturned if multiple records found. 1705 """ 1706 # TODO(fdeng): We should use job_id and key together as 1707 # a primary key in the db. 1708 return cls.objects.get(job_id=data['job_id'], key=data['key']) 1709 1710 1711 @classmethod 1712 def deserialize(cls, data): 1713 """Override deserialize in parent class. 1714 1715 Do not deserialize id as id is not kept consistent on master and shards. 1716 1717 @param data: A dictionary of data to deserialize. 1718 1719 @returns: A JobKeyval object. 1720 """ 1721 if data: 1722 data.pop('id') 1723 return super(JobKeyval, cls).deserialize(data) 1724 1725 1726 class Meta: 1727 """Metadata for class JobKeyval.""" 1728 db_table = 'afe_job_keyvals' 1729 1730 1731class IneligibleHostQueue(dbmodels.Model, model_logic.ModelExtensions): 1732 """Represents an ineligible host queue.""" 1733 job = dbmodels.ForeignKey(Job) 1734 host = dbmodels.ForeignKey(Host) 1735 1736 objects = model_logic.ExtendedManager() 1737 1738 class Meta: 1739 """Metadata for class IneligibleHostQueue.""" 1740 db_table = 'afe_ineligible_host_queues' 1741 1742 1743class HostQueueEntry(dbmodels.Model, model_logic.ModelExtensions): 1744 """Represents a host queue entry.""" 1745 1746 SERIALIZATION_LINKS_TO_FOLLOW = set(['meta_host']) 1747 SERIALIZATION_LINKS_TO_KEEP = set(['host']) 1748 SERIALIZATION_LOCAL_LINKS_TO_UPDATE = set(['aborted']) 1749 1750 1751 def custom_deserialize_relation(self, link, data): 1752 assert link == 'meta_host' 1753 self.meta_host = Label.deserialize(data) 1754 1755 1756 def sanity_check_update_from_shard(self, shard, updated_serialized, 1757 job_ids_sent): 1758 if self.job_id not in job_ids_sent: 1759 raise error.UnallowedRecordsSentToMaster( 1760 'Sent HostQueueEntry without corresponding ' 1761 'job entry: %s' % updated_serialized) 1762 1763 1764 Status = host_queue_entry_states.Status 1765 ACTIVE_STATUSES = host_queue_entry_states.ACTIVE_STATUSES 1766 COMPLETE_STATUSES = host_queue_entry_states.COMPLETE_STATUSES 1767 1768 job = dbmodels.ForeignKey(Job) 1769 host = dbmodels.ForeignKey(Host, blank=True, null=True) 1770 status = dbmodels.CharField(max_length=255) 1771 meta_host = dbmodels.ForeignKey(Label, blank=True, null=True, 1772 db_column='meta_host') 1773 active = dbmodels.BooleanField(default=False) 1774 complete = dbmodels.BooleanField(default=False) 1775 deleted = dbmodels.BooleanField(default=False) 1776 execution_subdir = dbmodels.CharField(max_length=255, blank=True, 1777 default='') 1778 # If atomic_group is set, this is a virtual HostQueueEntry that will 1779 # be expanded into many actual hosts within the group at schedule time. 1780 atomic_group = dbmodels.ForeignKey(AtomicGroup, blank=True, null=True) 1781 aborted = dbmodels.BooleanField(default=False) 1782 started_on = dbmodels.DateTimeField(null=True, blank=True) 1783 finished_on = dbmodels.DateTimeField(null=True, blank=True) 1784 1785 objects = model_logic.ExtendedManager() 1786 1787 1788 def __init__(self, *args, **kwargs): 1789 super(HostQueueEntry, self).__init__(*args, **kwargs) 1790 self._record_attributes(['status']) 1791 1792 1793 @classmethod 1794 def create(cls, job, host=None, meta_host=None, atomic_group=None, 1795 is_template=False): 1796 """Creates a new host queue entry. 1797 1798 @param cls: Implicit class object. 1799 @param job: The associated job. 1800 @param host: The associated host. 1801 @param meta_host: The associated meta host. 1802 @param atomic_group: The associated atomic group. 1803 @param is_template: Whether the status should be "Template". 1804 """ 1805 if is_template: 1806 status = cls.Status.TEMPLATE 1807 else: 1808 status = cls.Status.QUEUED 1809 1810 return cls(job=job, host=host, meta_host=meta_host, 1811 atomic_group=atomic_group, status=status) 1812 1813 1814 def save(self, *args, **kwargs): 1815 self._set_active_and_complete() 1816 super(HostQueueEntry, self).save(*args, **kwargs) 1817 self._check_for_updated_attributes() 1818 1819 1820 def execution_path(self): 1821 """ 1822 Path to this entry's results (relative to the base results directory). 1823 """ 1824 return server_utils.get_hqe_exec_path(self.job.tag(), 1825 self.execution_subdir) 1826 1827 1828 def host_or_metahost_name(self): 1829 """Returns the first non-None name found in priority order. 1830 1831 The priority order checked is: (1) host name; (2) meta host name; and 1832 (3) atomic group name. 1833 """ 1834 if self.host: 1835 return self.host.hostname 1836 elif self.meta_host: 1837 return self.meta_host.name 1838 else: 1839 assert self.atomic_group, "no host, meta_host or atomic group!" 1840 return self.atomic_group.name 1841 1842 1843 def _set_active_and_complete(self): 1844 if self.status in self.ACTIVE_STATUSES: 1845 self.active, self.complete = True, False 1846 elif self.status in self.COMPLETE_STATUSES: 1847 self.active, self.complete = False, True 1848 else: 1849 self.active, self.complete = False, False 1850 1851 1852 def on_attribute_changed(self, attribute, old_value): 1853 assert attribute == 'status' 1854 logging.info('%s/%d (%d) -> %s', self.host, self.job.id, self.id, 1855 self.status) 1856 1857 1858 def is_meta_host_entry(self): 1859 'True if this is a entry has a meta_host instead of a host.' 1860 return self.host is None and self.meta_host is not None 1861 1862 1863 # This code is shared between rpc_interface and models.HostQueueEntry. 1864 # Sadly due to circular imports between the 2 (crbug.com/230100) making it 1865 # a class method was the best way to refactor it. Attempting to put it in 1866 # rpc_utils or a new utils module failed as that would require us to import 1867 # models.py but to call it from here we would have to import the utils.py 1868 # thus creating a cycle. 1869 @classmethod 1870 def abort_host_queue_entries(cls, host_queue_entries): 1871 """Aborts a collection of host_queue_entries. 1872 1873 Abort these host queue entry and all host queue entries of jobs created 1874 by them. 1875 1876 @param host_queue_entries: List of host queue entries we want to abort. 1877 """ 1878 # This isn't completely immune to race conditions since it's not atomic, 1879 # but it should be safe given the scheduler's behavior. 1880 1881 # TODO(milleral): crbug.com/230100 1882 # The |abort_host_queue_entries| rpc does nearly exactly this, 1883 # however, trying to re-use the code generates some horrible 1884 # circular import error. I'd be nice to refactor things around 1885 # sometime so the code could be reused. 1886 1887 # Fixpoint algorithm to find the whole tree of HQEs to abort to 1888 # minimize the total number of database queries: 1889 children = set() 1890 new_children = set(host_queue_entries) 1891 while new_children: 1892 children.update(new_children) 1893 new_child_ids = [hqe.job_id for hqe in new_children] 1894 new_children = HostQueueEntry.objects.filter( 1895 job__parent_job__in=new_child_ids, 1896 complete=False, aborted=False).all() 1897 # To handle circular parental relationships 1898 new_children = set(new_children) - children 1899 1900 # Associate a user with the host queue entries that we're about 1901 # to abort so that we can look up who to blame for the aborts. 1902 now = datetime.now() 1903 user = User.current_user() 1904 aborted_hqes = [AbortedHostQueueEntry(queue_entry=hqe, 1905 aborted_by=user, aborted_on=now) for hqe in children] 1906 AbortedHostQueueEntry.objects.bulk_create(aborted_hqes) 1907 # Bulk update all of the HQEs to set the abort bit. 1908 child_ids = [hqe.id for hqe in children] 1909 HostQueueEntry.objects.filter(id__in=child_ids).update(aborted=True) 1910 1911 1912 def abort(self): 1913 """ Aborts this host queue entry. 1914 1915 Abort this host queue entry and all host queue entries of jobs created by 1916 this one. 1917 1918 """ 1919 if not self.complete and not self.aborted: 1920 HostQueueEntry.abort_host_queue_entries([self]) 1921 1922 1923 @classmethod 1924 def compute_full_status(cls, status, aborted, complete): 1925 """Returns a modified status msg if the host queue entry was aborted. 1926 1927 @param cls: Implicit class object. 1928 @param status: The original status message. 1929 @param aborted: Whether the host queue entry was aborted. 1930 @param complete: Whether the host queue entry was completed. 1931 """ 1932 if aborted and not complete: 1933 return 'Aborted (%s)' % status 1934 return status 1935 1936 1937 def full_status(self): 1938 """Returns the full status of this host queue entry, as a string.""" 1939 return self.compute_full_status(self.status, self.aborted, 1940 self.complete) 1941 1942 1943 def _postprocess_object_dict(self, object_dict): 1944 object_dict['full_status'] = self.full_status() 1945 1946 1947 class Meta: 1948 """Metadata for class HostQueueEntry.""" 1949 db_table = 'afe_host_queue_entries' 1950 1951 1952 1953 def __unicode__(self): 1954 hostname = None 1955 if self.host: 1956 hostname = self.host.hostname 1957 return u"%s/%d (%d)" % (hostname, self.job.id, self.id) 1958 1959 1960class AbortedHostQueueEntry(dbmodels.Model, model_logic.ModelExtensions): 1961 """Represents an aborted host queue entry.""" 1962 queue_entry = dbmodels.OneToOneField(HostQueueEntry, primary_key=True) 1963 aborted_by = dbmodels.ForeignKey(User) 1964 aborted_on = dbmodels.DateTimeField() 1965 1966 objects = model_logic.ExtendedManager() 1967 1968 1969 def save(self, *args, **kwargs): 1970 self.aborted_on = datetime.now() 1971 super(AbortedHostQueueEntry, self).save(*args, **kwargs) 1972 1973 class Meta: 1974 """Metadata for class AbortedHostQueueEntry.""" 1975 db_table = 'afe_aborted_host_queue_entries' 1976 1977 1978class RecurringRun(dbmodels.Model, model_logic.ModelExtensions): 1979 """\ 1980 job: job to use as a template 1981 owner: owner of the instantiated template 1982 start_date: Run the job at scheduled date 1983 loop_period: Re-run (loop) the job periodically 1984 (in every loop_period seconds) 1985 loop_count: Re-run (loop) count 1986 """ 1987 1988 job = dbmodels.ForeignKey(Job) 1989 owner = dbmodels.ForeignKey(User) 1990 start_date = dbmodels.DateTimeField() 1991 loop_period = dbmodels.IntegerField(blank=True) 1992 loop_count = dbmodels.IntegerField(blank=True) 1993 1994 objects = model_logic.ExtendedManager() 1995 1996 class Meta: 1997 """Metadata for class RecurringRun.""" 1998 db_table = 'afe_recurring_run' 1999 2000 def __unicode__(self): 2001 return u'RecurringRun(job %s, start %s, period %s, count %s)' % ( 2002 self.job.id, self.start_date, self.loop_period, self.loop_count) 2003 2004 2005class SpecialTask(dbmodels.Model, model_logic.ModelExtensions): 2006 """\ 2007 Tasks to run on hosts at the next time they are in the Ready state. Use this 2008 for high-priority tasks, such as forced repair or forced reinstall. 2009 2010 host: host to run this task on 2011 task: special task to run 2012 time_requested: date and time the request for this task was made 2013 is_active: task is currently running 2014 is_complete: task has finished running 2015 is_aborted: task was aborted 2016 time_started: date and time the task started 2017 time_finished: date and time the task finished 2018 queue_entry: Host queue entry waiting on this task (or None, if task was not 2019 started in preparation of a job) 2020 """ 2021 Task = enum.Enum('Verify', 'Cleanup', 'Repair', 'Reset', 'Provision', 2022 string_values=True) 2023 2024 host = dbmodels.ForeignKey(Host, blank=False, null=False) 2025 task = dbmodels.CharField(max_length=64, choices=Task.choices(), 2026 blank=False, null=False) 2027 requested_by = dbmodels.ForeignKey(User) 2028 time_requested = dbmodels.DateTimeField(auto_now_add=True, blank=False, 2029 null=False) 2030 is_active = dbmodels.BooleanField(default=False, blank=False, null=False) 2031 is_complete = dbmodels.BooleanField(default=False, blank=False, null=False) 2032 is_aborted = dbmodels.BooleanField(default=False, blank=False, null=False) 2033 time_started = dbmodels.DateTimeField(null=True, blank=True) 2034 queue_entry = dbmodels.ForeignKey(HostQueueEntry, blank=True, null=True) 2035 success = dbmodels.BooleanField(default=False, blank=False, null=False) 2036 time_finished = dbmodels.DateTimeField(null=True, blank=True) 2037 2038 objects = model_logic.ExtendedManager() 2039 2040 2041 def save(self, **kwargs): 2042 if self.queue_entry: 2043 self.requested_by = User.objects.get( 2044 login=self.queue_entry.job.owner) 2045 super(SpecialTask, self).save(**kwargs) 2046 2047 2048 def execution_path(self): 2049 """Returns the execution path for a special task.""" 2050 return server_utils.get_special_task_exec_path( 2051 self.host.hostname, self.id, self.task, self.time_requested) 2052 2053 2054 # property to emulate HostQueueEntry.status 2055 @property 2056 def status(self): 2057 """Returns a host queue entry status appropriate for a speical task.""" 2058 return server_utils.get_special_task_status( 2059 self.is_complete, self.success, self.is_active) 2060 2061 2062 # property to emulate HostQueueEntry.started_on 2063 @property 2064 def started_on(self): 2065 """Returns the time at which this special task started.""" 2066 return self.time_started 2067 2068 2069 @classmethod 2070 def schedule_special_task(cls, host, task): 2071 """Schedules a special task on a host if not already scheduled. 2072 2073 @param cls: Implicit class object. 2074 @param host: The host to use. 2075 @param task: The task to schedule. 2076 """ 2077 existing_tasks = SpecialTask.objects.filter(host__id=host.id, task=task, 2078 is_active=False, 2079 is_complete=False) 2080 if existing_tasks: 2081 return existing_tasks[0] 2082 2083 special_task = SpecialTask(host=host, task=task, 2084 requested_by=User.current_user()) 2085 special_task.save() 2086 return special_task 2087 2088 2089 def abort(self): 2090 """ Abort this special task.""" 2091 self.is_aborted = True 2092 self.save() 2093 2094 2095 def activate(self): 2096 """ 2097 Sets a task as active and sets the time started to the current time. 2098 """ 2099 logging.info('Starting: %s', self) 2100 self.is_active = True 2101 self.time_started = datetime.now() 2102 self.save() 2103 2104 2105 def finish(self, success): 2106 """Sets a task as completed. 2107 2108 @param success: Whether or not the task was successful. 2109 """ 2110 logging.info('Finished: %s', self) 2111 self.is_active = False 2112 self.is_complete = True 2113 self.success = success 2114 if self.time_started: 2115 self.time_finished = datetime.now() 2116 self.save() 2117 2118 2119 class Meta: 2120 """Metadata for class SpecialTask.""" 2121 db_table = 'afe_special_tasks' 2122 2123 2124 def __unicode__(self): 2125 result = u'Special Task %s (host %s, task %s, time %s)' % ( 2126 self.id, self.host, self.task, self.time_requested) 2127 if self.is_complete: 2128 result += u' (completed)' 2129 elif self.is_active: 2130 result += u' (active)' 2131 2132 return result 2133 2134 2135class StableVersion(dbmodels.Model, model_logic.ModelExtensions): 2136 2137 board = dbmodels.CharField(max_length=255, unique=True) 2138 version = dbmodels.CharField(max_length=255) 2139 2140 class Meta: 2141 """Metadata for class StableVersion.""" 2142 db_table = 'afe_stable_versions' 2143