models.py revision 6f85a1f0e9f3dc342d376a9245075106eeedc011
1import datetime 2from django.db import models as dbmodels 3from frontend.afe import enum 4from frontend import settings 5 6 7class ValidationError(Exception): 8 """\ 9 Data validation error in adding or updating an object. The associated 10 value is a dictionary mapping field names to error strings. 11 """ 12 13 14class AclAccessViolation(Exception): 15 """\ 16 Raised when an operation is attempted with proper permissions as 17 dictated by ACLs. 18 """ 19 20 21class ModelExtensions(object): 22 """\ 23 Mixin with convenience functions for models, built on top of the 24 default Django model functions. 25 """ 26 # TODO: at least some of these functions really belong in a custom 27 # Manager class 28 29 field_dict = None 30 # subclasses should override if they want to support smart_get() by name 31 name_field = None 32 33 34 @classmethod 35 def get_field_dict(cls): 36 if cls.field_dict is None: 37 cls.field_dict = {} 38 for field in cls._meta.fields: 39 cls.field_dict[field.name] = field 40 return cls.field_dict 41 42 43 @classmethod 44 def clean_foreign_keys(cls, data): 45 """\ 46 Convert foreign key fields in data from <field>_id to just 47 <field> 48 """ 49 for field in cls._meta.fields: 50 if field.rel: 51 data[field.name] = data[field.column] 52 del data[field.column] 53 54 55 # TODO(showard) - is there a way to not have to do this? 56 @classmethod 57 def provide_default_values(cls, data): 58 """\ 59 Provide default values for fields with default values which have 60 nothing passed in. 61 62 For CharField and TextField fields with "blank=True", if nothing 63 is passed, we fill in an empty string value, even if there's no 64 default set. 65 """ 66 new_data = dict(data) 67 field_dict = cls.get_field_dict() 68 for name, obj in field_dict.iteritems(): 69 if data.get(name) is not None: 70 continue 71 if obj.default is not dbmodels.fields.NOT_PROVIDED: 72 new_data[name] = obj.default 73 elif (isinstance(obj, dbmodels.CharField) or 74 isinstance(obj, dbmodels.TextField)): 75 new_data[name] = '' 76 return new_data 77 78 79 @classmethod 80 def convert_human_readable_values(cls, data, to_human_readable=False): 81 """\ 82 Performs conversions on user-supplied field data, to make it 83 easier for users to pass human-readable data. 84 85 For all fields that have choice sets, convert their values 86 from human-readable strings to enum values, if necessary. This 87 allows users to pass strings instead of the corresponding 88 integer values. 89 90 For all foreign key fields, call smart_get with the supplied 91 data. This allows the user to pass either an ID value or 92 the name of the object as a string. 93 94 If to_human_readable=True, perform the inverse - i.e. convert 95 numeric values to human readable values. 96 """ 97 new_data = dict(data) 98 field_dict = cls.get_field_dict() 99 for field_name in data: 100 field_obj = field_dict[field_name] 101 # convert enum values 102 if field_obj.choices: 103 for choice_data in field_obj.choices: 104 # choice_data is (value, name) 105 if to_human_readable: 106 from_val, to_val = choice_data 107 else: 108 to_val, from_val = choice_data 109 if from_val == data[field_name]: 110 new_data[field_name] = to_val 111 break 112 # convert foreign key values 113 elif field_obj.rel: 114 dest_obj = field_obj.rel.to.smart_get( 115 data[field_name]) 116 if to_human_readable: 117 new_data[field_name] = ( 118 getattr(dest_obj, 119 dest_obj.name_field)) 120 else: 121 new_data[field_name] = dest_obj.id 122 return new_data 123 124 125 @classmethod 126 def validate_field_names(cls, data): 127 'Checks for extraneous fields in data.' 128 errors = {} 129 field_dict = cls.get_field_dict() 130 for field_name in data: 131 if field_name not in field_dict: 132 errors[field_name] = 'No field of this name' 133 return errors 134 135 136 @classmethod 137 def prepare_data_args(cls, data, kwargs): 138 'Common preparation for add_object and update_object' 139 data = dict(data) # don't modify the default keyword arg 140 data.update(kwargs) 141 # must check for extraneous field names here, while we have the 142 # data in a dict 143 errors = cls.validate_field_names(data) 144 if errors: 145 raise ValidationError(errors) 146 data = cls.convert_human_readable_values(data) 147 return data 148 149 150 def validate_unique(self): 151 """\ 152 Validate that unique fields are unique. Django manipulators do 153 this too, but they're a huge pain to use manually. Trust me. 154 """ 155 errors = {} 156 cls = type(self) 157 field_dict = self.get_field_dict() 158 for field_name, field_obj in field_dict.iteritems(): 159 if not field_obj.unique: 160 continue 161 162 value = getattr(self, field_name) 163 existing_objs = cls.objects.filter( 164 **{field_name : value}) 165 num_existing = existing_objs.count() 166 167 if num_existing == 0: 168 continue 169 if num_existing == 1 and existing_objs[0].id == self.id: 170 continue 171 errors[field_name] = ( 172 'This value must be unique (%s)' % (value)) 173 return errors 174 175 176 def do_validate(self): 177 errors = self.validate() 178 unique_errors = self.validate_unique() 179 for field_name, error in unique_errors.iteritems(): 180 errors.setdefault(field_name, error) 181 if errors: 182 raise ValidationError(errors) 183 184 185 # actually (externally) useful methods follow 186 187 @classmethod 188 def add_object(cls, data={}, **kwargs): 189 """\ 190 Returns a new object created with the given data (a dictionary 191 mapping field names to values). Merges any extra keyword args 192 into data. 193 """ 194 data = cls.prepare_data_args(data, kwargs) 195 data = cls.provide_default_values(data) 196 obj = cls(**data) 197 obj.do_validate() 198 obj.save() 199 return obj 200 201 202 def update_object(self, data={}, **kwargs): 203 """\ 204 Updates the object with the given data (a dictionary mapping 205 field names to values). Merges any extra keyword args into 206 data. 207 """ 208 data = self.prepare_data_args(data, kwargs) 209 for field_name, value in data.iteritems(): 210 if value is not None: 211 setattr(self, field_name, value) 212 self.do_validate() 213 self.save() 214 215 216 @classmethod 217 def query_objects(cls, filter_data): 218 """\ 219 Returns a QuerySet object for querying the given model_class 220 with the given filter_data. Optional special arguments in 221 filter_data include: 222 -query_start: index of first return to return 223 -query_limit: maximum number of results to return 224 -sort_by: list of fields to sort on. prefixing a '-' onto a 225 field name changes the sort to descending order. 226 -extra_args: keyword args to pass to query.extra() (see Django 227 DB layer documentation) 228 """ 229 query_start = filter_data.pop('query_start', None) 230 query_limit = filter_data.pop('query_limit', None) 231 if query_start and not query_limit: 232 raise ValueError('Cannot pass query_start without ' 233 'query_limit') 234 sort_by = filter_data.pop('sort_by', []) 235 extra_args = filter_data.pop('extra_args', None) 236 query_dict = {} 237 for field, value in filter_data.iteritems(): 238 query_dict[field] = value 239 query = cls.objects.filter(**query_dict) 240 if extra_args: 241 query = query.extra(**extra_args) 242 assert isinstance(sort_by, list) or isinstance(sort_by, tuple) 243 query = query.order_by(*sort_by) 244 if query_start is not None and query_limit is not None: 245 query_limit += query_start 246 return query[query_start:query_limit] 247 248 249 @classmethod 250 def query_count(cls, filter_data): 251 """\ 252 Like query_objects, but retreive only the count of results. 253 """ 254 filter_data.pop('query_start', None) 255 filter_data.pop('query_limit', None) 256 return cls.query_objects(filter_data).count() 257 258 259 @classmethod 260 def clean_object_dicts(cls, field_dicts): 261 """\ 262 Take a list of dicts corresponding to object (as returned by 263 query.values()) and clean the data to be more suitable for 264 returning to the user. 265 """ 266 for i in range(len(field_dicts)): 267 cls.clean_foreign_keys(field_dicts[i]) 268 field_dicts[i] = cls.convert_human_readable_values( 269 field_dicts[i], to_human_readable=True) 270 271 272 @classmethod 273 def list_objects(cls, filter_data): 274 """\ 275 Like query_objects, but return a list of dictionaries. 276 """ 277 query = cls.query_objects(filter_data) 278 field_dicts = list(query.values()) 279 cls.clean_object_dicts(field_dicts) 280 return field_dicts 281 282 283 @classmethod 284 def smart_get(cls, *args, **kwargs): 285 """\ 286 smart_get(integer) -> get object by ID 287 smart_get(string) -> get object by name_field 288 smart_get(keyword args) -> normal ModelClass.objects.get() 289 """ 290 assert bool(args) ^ bool(kwargs) 291 if args: 292 assert len(args) == 1 293 arg = args[0] 294 if isinstance(arg, int) or isinstance(arg, long): 295 return cls.objects.get(id=arg) 296 if isinstance(arg, str) or isinstance(arg, unicode): 297 return cls.objects.get( 298 **{cls.name_field : arg}) 299 raise ValueError( 300 'Invalid positional argument: %s (%s)' % ( 301 str(arg), type(arg))) 302 return cls.objects.get(**kwargs) 303 304 305 def get_object_dict(self): 306 """\ 307 Return a dictionary mapping fields to this object's values. 308 """ 309 return dict((field_name, getattr(self, field_name)) 310 for field_name in self.get_field_dict().iterkeys()) 311 312 313class Label(dbmodels.Model, ModelExtensions): 314 """\ 315 Required: 316 name: label name 317 318 Optional: 319 kernel_config: url/path to kernel config to use for jobs run on this 320 label 321 platform: if True, this is a platform label (defaults to False) 322 """ 323 name = dbmodels.CharField(maxlength=255, unique=True) 324 kernel_config = dbmodels.CharField(maxlength=255, blank=True) 325 platform = dbmodels.BooleanField(default=False) 326 327 name_field = 'name' 328 329 330 def enqueue_job(self, job): 331 'Enqueue a job on any host of this label.' 332 queue_entry = HostQueueEntry(meta_host=self, job=job, 333 status=Job.Status.QUEUED, 334 priority=job.priority) 335 queue_entry.save() 336 337 338 def block_auto_assign(self, job): 339 """\ 340 Placeholder to allow labels to be used in place of hosts 341 (as meta-hosts). 342 """ 343 pass 344 345 346 class Meta: 347 db_table = 'labels' 348 349 class Admin: 350 list_display = ('name', 'kernel_config') 351 352 def __str__(self): 353 return self.name 354 355 356class Host(dbmodels.Model, ModelExtensions): 357 """\ 358 Required: 359 hostname 360 361 optional: 362 locked: host is locked and will not be queued 363 364 Internal: 365 synch_id: currently unused 366 status: string describing status of host 367 """ 368 Status = enum.Enum('Verifying', 'Running', 'Ready', 'Repairing', 369 'Repair Failed', 'Dead', string_values=True) 370 371 hostname = dbmodels.CharField(maxlength=255, unique=True) 372 labels = dbmodels.ManyToManyField(Label, blank=True, 373 filter_interface=dbmodels.HORIZONTAL) 374 locked = dbmodels.BooleanField(default=False) 375 synch_id = dbmodels.IntegerField(blank=True, null=True, 376 editable=settings.FULL_ADMIN) 377 status = dbmodels.CharField(maxlength=255, default=Status.READY, 378 choices=Status.choices(), 379 editable=settings.FULL_ADMIN) 380 381 name_field = 'hostname' 382 383 384 def save(self): 385 # extra spaces in the hostname can be a sneaky source of errors 386 self.hostname = self.hostname.strip() 387 # is this a new object being saved for the first time? 388 first_time = (self.id is None) 389 super(Host, self).save() 390 if first_time: 391 everyone = AclGroup.objects.get(name='Everyone') 392 everyone.hosts.add(self) 393 394 395 def enqueue_job(self, job): 396 ' Enqueue a job on this host.' 397 queue_entry = HostQueueEntry(host=self, job=job, 398 status=Job.Status.QUEUED, 399 priority=job.priority) 400 # allow recovery of dead hosts from the frontend 401 if not self.active_queue_entry() and self.is_dead(): 402 self.status = Host.Status.READY 403 self.save() 404 queue_entry.save() 405 406 407 def block_auto_assign(self, job): 408 'Block this host from being assigned to a job.' 409 block = IneligibleHostQueue(job=job, host=self) 410 block.save() 411 412 413 def platform(self): 414 # TODO(showard): slighly hacky? 415 platforms = self.labels.filter(platform=True) 416 if len(platforms) == 0: 417 return None 418 return platforms[0] 419 platform.short_description = 'Platform' 420 421 422 def is_dead(self): 423 return self.status == Host.Status.REPAIR_FAILED 424 425 426 def active_queue_entry(self): 427 active = list(self.hostqueueentry_set.filter(active=True)) 428 if not active: 429 return None 430 assert len(active) == 1, ('More than one active entry for ' 431 'host ' + self.hostname) 432 return active[0] 433 434 435 class Meta: 436 db_table = 'hosts' 437 438 class Admin: 439 # TODO(showard) - showing platform requires a SQL query for 440 # each row (since labels are many-to-many) - should we remove 441 # it? 442 list_display = ('hostname', 'platform', 'locked', 'status') 443 list_filter = ('labels', 'locked') 444 search_fields = ('hostname', 'status') 445 446 def __str__(self): 447 return self.hostname 448 449 450class Test(dbmodels.Model, ModelExtensions): 451 """\ 452 Required: 453 name: test name 454 test_type: Client or Server 455 path: path to pass to run_test() 456 synch_type: whether the test should run synchronously or asynchronously 457 458 Optional: 459 test_class: used for categorization of tests 460 description: arbirary text description 461 """ 462 Classes = enum.Enum('Kernel', 'Hardware', 'Canned Test Sets', 463 string_values=True) 464 SynchType = enum.Enum('Asynchronous', 'Synchronous', start_value=1) 465 # TODO(showard) - this should be merged with Job.ControlType (but right 466 # now they use opposite values) 467 Types = enum.Enum('Client', 'Server', start_value=1) 468 469 name = dbmodels.CharField(maxlength=255, unique=True) 470 test_class = dbmodels.CharField(maxlength=255, 471 choices=Classes.choices()) 472 description = dbmodels.TextField(blank=True) 473 test_type = dbmodels.SmallIntegerField(choices=Types.choices()) 474 synch_type = dbmodels.SmallIntegerField(choices=SynchType.choices(), 475 default=SynchType.ASYNCHRONOUS) 476 path = dbmodels.CharField(maxlength=255) 477 478 name_field = 'name' 479 480 481 class Meta: 482 db_table = 'autotests' 483 484 class Admin: 485 fields = ( 486 (None, {'fields' : 487 ('name', 'test_class', 'test_type', 'synch_type', 488 'path', 'description')}), 489 ) 490 list_display = ('name', 'test_type', 'synch_type', 491 'description') 492 search_fields = ('name',) 493 494 def __str__(self): 495 return self.name 496 497 498class User(dbmodels.Model, ModelExtensions): 499 """\ 500 Required: 501 login :user login name 502 503 Optional: 504 access_level: 0=User (default), 1=Admin, 100=Root 505 """ 506 ACCESS_ROOT = 100 507 ACCESS_ADMIN = 1 508 ACCESS_USER = 0 509 510 login = dbmodels.CharField(maxlength=255, unique=True) 511 access_level = dbmodels.IntegerField(default=ACCESS_USER, blank=True) 512 513 name_field = 'login' 514 515 516 def save(self): 517 # is this a new object being saved for the first time? 518 first_time = (self.id is None) 519 super(User, self).save() 520 if first_time: 521 everyone = AclGroup.objects.get(name='Everyone') 522 everyone.users.add(self) 523 524 525 def has_access(self, target): 526 if self.access_level >= self.ACCESS_ROOT: 527 return True 528 529 if isinstance(target, int): 530 return self.access_level >= target 531 if isinstance(target, Job): 532 return (target.owner == self.login or 533 self.access_level >= self.ACCESS_ADMIN) 534 if isinstance(target, Host): 535 acl_intersect = [group 536 for group in self.aclgroup_set.all() 537 if group in target.aclgroup_set.all()] 538 return bool(acl_intersect) 539 if isinstance(target, User): 540 return self.access_level >= target.access_level 541 raise ValueError('Invalid target type') 542 543 544 class Meta: 545 db_table = 'users' 546 547 class Admin: 548 list_display = ('login', 'access_level') 549 search_fields = ('login',) 550 551 def __str__(self): 552 return self.login 553 554 555class AclGroup(dbmodels.Model, ModelExtensions): 556 """\ 557 Required: 558 name: name of ACL group 559 560 Optional: 561 description: arbitrary description of group 562 """ 563 name = dbmodels.CharField(maxlength=255, unique=True) 564 description = dbmodels.CharField(maxlength=255, blank=True) 565 users = dbmodels.ManyToManyField(User, 566 filter_interface=dbmodels.HORIZONTAL) 567 hosts = dbmodels.ManyToManyField(Host, 568 filter_interface=dbmodels.HORIZONTAL) 569 570 name_field = 'name' 571 572 573 class Meta: 574 db_table = 'acl_groups' 575 576 class Admin: 577 list_display = ('name', 'description') 578 search_fields = ('name',) 579 580 def __str__(self): 581 return self.name 582 583# hack to make the column name in the many-to-many DB tables match the one 584# generated by ruby 585AclGroup._meta.object_name = 'acl_group' 586 587 588class JobManager(dbmodels.Manager): 589 'Custom manager to provide efficient status counts querying.' 590 def get_status_counts(self, job_ids): 591 """\ 592 Returns a dictionary mapping the given job IDs to their status 593 count dictionaries. 594 """ 595 if not job_ids: 596 return {} 597 id_list = '(%s)' % ','.join(str(job_id) for job_id in job_ids) 598 from django.db import connection 599 cursor = connection.cursor() 600 cursor.execute(""" 601 SELECT job_id, status, COUNT(*) 602 FROM host_queue_entries 603 WHERE job_id IN %s 604 GROUP BY job_id, status 605 """ % id_list) 606 all_job_counts = {} 607 for job_id in job_ids: 608 all_job_counts[job_id] = {} 609 for job_id, status, count in cursor.fetchall(): 610 all_job_counts[job_id][status] = count 611 return all_job_counts 612 613 614class Job(dbmodels.Model, ModelExtensions): 615 """\ 616 owner: username of job owner 617 name: job name (does not have to be unique) 618 priority: Low, Medium, High, Urgent (or 0-3) 619 control_file: contents of control file 620 control_type: Client or Server 621 created_on: date of job creation 622 submitted_on: date of job submission 623 synch_type: Asynchronous or Synchronous (i.e. job must run on all hosts 624 simultaneously; used for server-side control files) 625 synch_count: ??? 626 synchronizing: for scheduler use 627 """ 628 Priority = enum.Enum('Low', 'Medium', 'High', 'Urgent') 629 ControlType = enum.Enum('Server', 'Client', start_value=1) 630 Status = enum.Enum('Created', 'Queued', 'Pending', 'Running', 631 'Completed', 'Abort', 'Aborting', 'Aborted', 632 'Failed', string_values=True) 633 634 owner = dbmodels.CharField(maxlength=255) 635 name = dbmodels.CharField(maxlength=255) 636 priority = dbmodels.SmallIntegerField(choices=Priority.choices(), 637 blank=True, # to allow 0 638 default=Priority.MEDIUM) 639 control_file = dbmodels.TextField() 640 control_type = dbmodels.SmallIntegerField(choices=ControlType.choices(), 641 blank=True) # to allow 0 642 created_on = dbmodels.DateTimeField(auto_now_add=True) 643 synch_type = dbmodels.SmallIntegerField( 644 blank=True, null=True, choices=Test.SynchType.choices()) 645 synch_count = dbmodels.IntegerField(blank=True, null=True) 646 synchronizing = dbmodels.BooleanField(default=False) 647 648 649 # custom manager 650 objects = JobManager() 651 652 653 def is_server_job(self): 654 return self.control_type == self.ControlType.SERVER 655 656 657 @classmethod 658 def create(cls, owner, name, priority, control_file, control_type, 659 hosts, synch_type): 660 """\ 661 Creates a job by taking some information (the listed args) 662 and filling in the rest of the necessary information. 663 """ 664 job = cls.add_object( 665 owner=owner, name=name, priority=priority, 666 control_file=control_file, control_type=control_type, 667 synch_type=synch_type) 668 669 if job.synch_type == Test.SynchType.SYNCHRONOUS: 670 job.synch_count = len(hosts) 671 else: 672 if len(hosts) == 0: 673 errors = {'hosts': 674 'asynchronous jobs require at least' 675 + ' one host to run on'} 676 raise ValidationError(errors) 677 job.save() 678 return job 679 680 681 def queue(self, hosts): 682 'Enqueue a job on the given hosts.' 683 for host in hosts: 684 host.enqueue_job(self) 685 host.block_auto_assign(self) 686 687 688 def requeue(self, new_owner): 689 'Creates a new job identical to this one' 690 hosts = [queue_entry.meta_host or queue_entry.host 691 for queue_entry in self.hostqueueentry_set.all()] 692 new_job = Job.create( 693 owner=new_owner, name=self.name, priority=self.priority, 694 control_file=self.control_file, 695 control_type=self.control_type, hosts=hosts, 696 synch_type=self.synch_type) 697 new_job.queue(hosts) 698 return new_job 699 700 701 def abort(self): 702 for queue_entry in self.hostqueueentry_set.all(): 703 if queue_entry.active: 704 queue_entry.status = Job.Status.ABORT 705 elif not queue_entry.complete: 706 queue_entry.status = Job.Status.ABORTED 707 queue_entry.active = False 708 queue_entry.complete = True 709 queue_entry.save() 710 711 712 def user(self): 713 try: 714 return User.objects.get(login=self.owner) 715 except self.DoesNotExist: 716 return None 717 718 719 class Meta: 720 db_table = 'jobs' 721 722 if settings.FULL_ADMIN: 723 class Admin: 724 list_display = ('id', 'owner', 'name', 'control_type') 725 726 def __str__(self): 727 return '%s (%s-%s)' % (self.name, self.id, self.owner) 728 729 730class IneligibleHostQueue(dbmodels.Model): 731 job = dbmodels.ForeignKey(Job) 732 host = dbmodels.ForeignKey(Host) 733 734 class Meta: 735 db_table = 'ineligible_host_queues' 736 737 if settings.FULL_ADMIN: 738 class Admin: 739 list_display = ('id', 'job', 'host') 740 741 742class HostQueueEntry(dbmodels.Model, ModelExtensions): 743 job = dbmodels.ForeignKey(Job) 744 host = dbmodels.ForeignKey(Host, blank=True, null=True) 745 priority = dbmodels.SmallIntegerField() 746 status = dbmodels.CharField(maxlength=255) 747 meta_host = dbmodels.ForeignKey(Label, blank=True, null=True, 748 db_column='meta_host') 749 active = dbmodels.BooleanField(default=False) 750 complete = dbmodels.BooleanField(default=False) 751 752 753 def is_meta_host_entry(self): 754 'True if this is a entry has a meta_host instead of a host.' 755 return self.host is None and self.meta_host is not None 756 757 758 def save(self): 759 if self.host: 760 user = self.job.user() 761 if user is None or not user.has_access(self.host): 762 raise AclAccessViolation( 763 'User %s does not have permission to ' 764 'access host %s' % (self.job.owner, 765 self.host.hostname)) 766 super(HostQueueEntry, self).save() 767 768 769 class Meta: 770 db_table = 'host_queue_entries' 771 772 if settings.FULL_ADMIN: 773 class Admin: 774 list_display = ('id', 'job', 'host', 'status', 775 'meta_host') 776