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