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