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