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