diff --git a/Makefile b/Makefile index b206e2ba87..8bf1ea75c2 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,7 @@ runserver: celeryd: # run to start the background celery worker - python manage.py celeryd -l DEBUG -B + python manage.py celeryd -l DEBUG -B --autoreload # already done and should not have to happen again: # diff --git a/lib/main/admin.py b/lib/main/admin.py index 2728cb080c..d8a15d4db3 100644 --- a/lib/main/admin.py +++ b/lib/main/admin.py @@ -198,9 +198,9 @@ class CredentialAdmin(BaseModelAdmin): fieldsets = ( (None, {'fields': (('name', 'active'), ('user', 'team'), 'description')}), - (_('Auth Info'), {'fields': ('default_username', 'ssh_key_data', - 'ssh_key_unlock', 'ssh_password', - 'sudo_password')}), + (_('Auth Info'), {'fields': (('ssh_username', 'ssh_password'), + 'ssh_key_data', 'ssh_key_unlock', + ('sudo_username', 'sudo_password'))}), #(_('Tags'), {'fields': ('tags',)}), (_('Audit Trail'), {'fields': ('creation_date', 'created_by', 'audit_trail',)}), ) @@ -245,6 +245,9 @@ class JobTemplateAdmin(BaseModelAdmin): 'get_create_link_display', 'get_jobs_link_display')}), (_('Job Parameters'), {'fields': ('inventory', 'project', 'playbook', 'credential', 'job_type')}), + (_('More Options'), {'fields': ('use_sudo', 'forks', 'limit', + 'verbosity', 'extra_vars'), + 'classes': ('collapse',)}), #(_('Tags'), {'fields': ('tags',)}), (_('Audit Trail'), {'fields': ('creation_date', 'created_by', 'audit_trail',)}), @@ -274,6 +277,17 @@ class JobTemplateAdmin(BaseModelAdmin): create_opts['playbook'] = obj.playbook if obj.credential: create_opts['credential'] = obj.credential.pk + if obj.use_sudo is not None: + # Assume these are the defaults for a null boolean field select. + create_opts['use_sudo'] = 2 if obj.use_sudo else 3 + if obj.forks: + create_opts['forks'] = obj.forks + if obj.limit: + create_opts['limit'] = obj.limit + if obj.verbosity: + create_opts['verbosity'] = obj.verbosity + if obj.extra_vars: + create_opts['extra_vars'] = json.dumps(obj.extra_vars) create_url += '?%s' % urllib.urlencode(create_opts) return format_html('{1}', create_url, 'Create Job') get_create_link_display.short_description = _('Create Job') @@ -308,8 +322,11 @@ class JobAdmin(BaseModelAdmin): fieldsets = ( (None, {'fields': ('name', 'job_template', 'description')}), (_('Job Parameters'), {'fields': ('inventory', 'project', 'playbook', - 'credential', 'job_type', - 'start_job')}), + 'credential', 'job_type')}), + (_('More Options'), {'fields': ('use_sudo', 'forks', 'limit', + 'verbosity', 'extra_vars'), + 'classes': ('collapse',)}), + (_('Start/Cancel Job'), {'fields': ('start_job',)}), #(_('Tags'), {'fields': ('tags',)}), (_('Audit Trail'), {'fields': ('creation_date', 'created_by', 'audit_trail',)}), @@ -331,7 +348,8 @@ class JobAdmin(BaseModelAdmin): if obj and obj.pk and obj.status != 'new': ro_fields.extend(['name', 'description', 'job_template', 'inventory', 'project', 'playbook', 'credential', - 'job_type']) + 'job_type', 'use_sudo', 'forks', 'limit', + 'verbosity', 'extra_vars']) return ro_fields def get_fieldsets(self, request, obj=None): @@ -342,11 +360,12 @@ class JobAdmin(BaseModelAdmin): 'status' not in fs[1]['fields']] elif obj and obj.pk and obj.status != 'new': #print obj, obj.pk, obj.status - for fs in fsets: - # FIXME: Show start job on add view - if 'start_job' in fs[1]['fields']: - fs[1]['fields'] = [x for x in fs[1]['fields'] - if x != 'start_job'] + fsets = [fs for fs in fsets if 'start_job' not in fs[1]['fields']] + #for fs in fsets: + # # FIXME: Show start job on add view + # if 'start_job' in fs[1]['fields']: + # fs[1]['fields'] = [x for x in fs[1]['fields'] + # if x != 'start_job'] return fsets def get_inline_instances(self, request, obj=None): diff --git a/lib/main/migrations/0015_changes.py b/lib/main/migrations/0015_changes.py new file mode 100644 index 0000000000..244f1cb683 --- /dev/null +++ b/lib/main/migrations/0015_changes.py @@ -0,0 +1,378 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding field 'Job.use_sudo' + db.add_column(u'main_job', 'use_sudo', + self.gf('django.db.models.fields.NullBooleanField')(default=None, null=True, blank=True), + keep_default=False) + + # Adding field 'Job.forks' + db.add_column(u'main_job', 'forks', + self.gf('django.db.models.fields.PositiveIntegerField')(default=0, blank=True), + keep_default=False) + + # Adding field 'Job.limit' + db.add_column(u'main_job', 'limit', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True), + keep_default=False) + + # Adding field 'Job.verbosity' + db.add_column(u'main_job', 'verbosity', + self.gf('django.db.models.fields.PositiveIntegerField')(default=0, blank=True), + keep_default=False) + + # Adding field 'Job.extra_vars' + db.add_column(u'main_job', 'extra_vars', + self.gf('jsonfield.fields.JSONField')(default='', blank=True), + keep_default=False) + + # Adding field 'Job.cancel_flag' + db.add_column(u'main_job', 'cancel_flag', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Deleting field 'Credential.default_username' + db.delete_column(u'main_credential', 'default_username') + + # Adding field 'Credential.ssh_username' + db.add_column(u'main_credential', 'ssh_username', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True), + keep_default=False) + + # Adding field 'Credential.sudo_username' + db.add_column(u'main_credential', 'sudo_username', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True), + keep_default=False) + + # Adding field 'JobTemplate.use_sudo' + db.add_column(u'main_jobtemplate', 'use_sudo', + self.gf('django.db.models.fields.NullBooleanField')(default=None, null=True, blank=True), + keep_default=False) + + # Adding field 'JobTemplate.forks' + db.add_column(u'main_jobtemplate', 'forks', + self.gf('django.db.models.fields.PositiveIntegerField')(default=0, blank=True), + keep_default=False) + + # Adding field 'JobTemplate.limit' + db.add_column(u'main_jobtemplate', 'limit', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True), + keep_default=False) + + # Adding field 'JobTemplate.verbosity' + db.add_column(u'main_jobtemplate', 'verbosity', + self.gf('django.db.models.fields.PositiveIntegerField')(default=0, blank=True), + keep_default=False) + + # Adding field 'JobTemplate.extra_vars' + db.add_column(u'main_jobtemplate', 'extra_vars', + self.gf('jsonfield.fields.JSONField')(default='', blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Job.use_sudo' + db.delete_column(u'main_job', 'use_sudo') + + # Deleting field 'Job.forks' + db.delete_column(u'main_job', 'forks') + + # Deleting field 'Job.limit' + db.delete_column(u'main_job', 'limit') + + # Deleting field 'Job.verbosity' + db.delete_column(u'main_job', 'verbosity') + + # Deleting field 'Job.extra_vars' + db.delete_column(u'main_job', 'extra_vars') + + # Deleting field 'Job.cancel_flag' + db.delete_column(u'main_job', 'cancel_flag') + + # Adding field 'Credential.default_username' + db.add_column(u'main_credential', 'default_username', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True), + keep_default=False) + + # Deleting field 'Credential.ssh_username' + db.delete_column(u'main_credential', 'ssh_username') + + # Deleting field 'Credential.sudo_username' + db.delete_column(u'main_credential', 'sudo_username') + + # Deleting field 'JobTemplate.use_sudo' + db.delete_column(u'main_jobtemplate', 'use_sudo') + + # Deleting field 'JobTemplate.forks' + db.delete_column(u'main_jobtemplate', 'forks') + + # Deleting field 'JobTemplate.limit' + db.delete_column(u'main_jobtemplate', 'limit') + + # Deleting field 'JobTemplate.verbosity' + db.delete_column(u'main_jobtemplate', 'verbosity') + + # Deleting field 'JobTemplate.extra_vars' + db.delete_column(u'main_jobtemplate', 'extra_vars') + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'main.audittrail': { + 'Meta': {'object_name': 'AuditTrail'}, + 'comment': ('django.db.models.fields.TextField', [], {}), + 'delta': ('django.db.models.fields.TextField', [], {}), + 'detail': ('django.db.models.fields.TextField', [], {}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['auth.User']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}), + 'resource_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['main.Tag']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}) + }, + 'main.credential': { + 'Meta': {'object_name': 'Credential'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'credential_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'credential\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'ssh_key_data': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'ssh_key_unlock': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'ssh_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'ssh_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'sudo_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'sudo_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'credential_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credentials'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Team']", 'blank': 'True', 'null': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'credentials'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': u"orm['auth.User']", 'blank': 'True', 'null': 'True'}) + }, + 'main.group': { + 'Meta': {'unique_together': "(('name', 'inventory'),)", 'object_name': 'Group'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'group_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'group\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'groups'", 'blank': 'True', 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'groups'", 'to': "orm['main.Inventory']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'parents': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'children'", 'blank': 'True', 'to': "orm['main.Group']"}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'group_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'variable_data': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'group'", 'unique': 'True', 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.VariableData']", 'blank': 'True', 'null': 'True'}) + }, + 'main.host': { + 'Meta': {'unique_together': "(('name', 'inventory'),)", 'object_name': 'Host'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'host_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'host\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts'", 'to': "orm['main.Inventory']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'host_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'variable_data': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'host'", 'unique': 'True', 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.VariableData']", 'blank': 'True', 'null': 'True'}) + }, + 'main.inventory': { + 'Meta': {'unique_together': "(('name', 'organization'),)", 'object_name': 'Inventory'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'inventory_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'inventory\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventories'", 'to': "orm['main.Organization']"}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'inventory_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}) + }, + 'main.job': { + 'Meta': {'object_name': 'Job'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'job_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'cancel_flag': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'celery_task_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'job\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Credential']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'extra_vars': ('jsonfield.fields.JSONField', [], {'default': "''", 'blank': 'True'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'jobs'", 'blank': 'True', 'through': u"orm['main.JobHostSummary']", 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobTemplate']", 'blank': 'True', 'null': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'playbook': ('django.db.models.fields.CharField', [], {'max_length': '1024'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['main.Project']"}), + 'result_stderr': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_stdout': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_traceback': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'new'", 'max_length': '20'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'job_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'use_sudo': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.jobevent': { + 'Meta': {'object_name': 'JobEvent'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'event': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'event_data': ('jsonfield.fields.JSONField', [], {'default': "''", 'blank': 'True'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_events'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Host']", 'blank': 'True', 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_events'", 'to': "orm['main.Job']"}) + }, + u'main.jobhostsummary': { + 'Meta': {'ordering': "('-pk',)", 'unique_together': "[('job', 'host')]", 'object_name': 'JobHostSummary'}, + 'changed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'dark': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_host_summaries'", 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_host_summaries'", 'to': "orm['main.Job']"}), + 'ok': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'processed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'skipped': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'main.jobtemplate': { + 'Meta': {'object_name': 'JobTemplate'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'jobtemplate_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'jobtemplate\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_templates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'extra_vars': ('jsonfield.fields.JSONField', [], {'default': "''", 'blank': 'True'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_templates'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'playbook': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_templates'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['main.Project']"}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'jobtemplate_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'use_sudo': ('django.db.models.fields.NullBooleanField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.organization': { + 'Meta': {'object_name': 'Organization'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'admins': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'admin_of_organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organization_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'organization\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': u"orm['main.Project']"}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organization_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.permission': { + 'Meta': {'object_name': 'Permission'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'permission_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'permission\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['main.Project']"}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'permission_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Team']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}) + }, + u'main.project': { + 'Meta': {'object_name': 'Project'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'project_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'project\', \'app_label\': u\'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'local_path': ('django.db.models.fields.FilePathField', [], {'path': "'/Users/chris/Sandbox/ansible-commander/lib/projects'", 'unique': 'True', 'max_length': '1024'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'project_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}) + }, + 'main.tag': { + 'Meta': {'object_name': 'Tag'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}) + }, + 'main.team': { + 'Meta': {'object_name': 'Team'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'team_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'team\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'teams'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Organization']"}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': u"orm['main.Project']"}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'team_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.variabledata': { + 'Meta': {'object_name': 'VariableData'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'audit_trail': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'variabledata_by_audit_trail'", 'blank': 'True', 'to': "orm['main.AuditTrail']"}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'related_name': '"{\'class\': \'variabledata\', \'app_label\': \'main\'}(class)s_created"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'creation_date': ('django.db.models.fields.DateField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'data': ('django.db.models.fields.TextField', [], {}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'variabledata_by_tag'", 'blank': 'True', 'to': "orm['main.Tag']"}) + } + } + + complete_apps = ['main'] \ No newline at end of file diff --git a/lib/main/models/__init__.py b/lib/main/models/__init__.py index a311e3744c..eddf439227 100644 --- a/lib/main/models/__init__.py +++ b/lib/main/models/__init__.py @@ -527,7 +527,7 @@ class Credential(CommonModelNameNotUnique): # if ssh_key_unlock is provided provide key password # if not provided, FAIL # - # default_username if set corresponds to -u on ansible-playbook, if unset -u root + # ssh_username if set corresponds to -u on ansible-playbook, if unset -u root # # STAGE 2: # OR if ssh_password is set instead, do not use SSH agent @@ -542,11 +542,59 @@ class Credential(CommonModelNameNotUnique): # # ansible-playbook foo.yml ... - ssh_key_data = models.TextField(blank=True, default='') - ssh_key_unlock = models.CharField(blank=True, default='', max_length=1024) - default_username = models.CharField(blank=True, default='', max_length=1024) - ssh_password = models.CharField(blank=True, default='', max_length=1024) - sudo_password = models.CharField(blank=True, default='', max_length=1024) + ssh_username = models.CharField( + blank=True, + default='', + max_length=1024, + verbose_name=_('SSH username'), + help_text=_('SSH username for a job using this credential.'), + ) + ssh_password = models.CharField( + blank=True, + default='', + max_length=1024, + verbose_name=_('SSH password'), + help_text=_('SSH password (or "ASK" to prompt the user).'), + ) + ssh_key_data = models.TextField( + blank=True, + default='', + verbose_name=_('SSH private key'), + help_text=_('RSA or DSA private key to be used instead of password.'), + ) + ssh_key_unlock = models.CharField( + max_length=1024, + blank=True, + default='', + verbose_name=_('SSH key unlock'), + help_text=_('Passphrase to unlock SSH private key if encrypted (or ' + '"ASK" to prompt the user).'), + ) + sudo_username = models.CharField( + max_length=1024, + blank=True, + default='', + help_text=_('Sudo username for a job using this credential.'), + ) + sudo_password = models.CharField( + max_length=1024, + blank=True, + default='', + help_text=_('Sudo password (or "ASK" to prompt the user).'), + ) + + @property + def needs_ssh_password(self): + return not self.ssh_key_data and self.ssh_password == 'ASK' + + @property + def needs_ssh_key_unlock(self): + return 'ENCRYPTED' in self.ssh_key_data and \ + (not self.ssh_key_unlock or self.ssh_key_unlock == 'ASK') + + @property + def needs_sudo_password(self): + return self.sudo_password == 'ASK' @classmethod def can_user_administrate(cls, user, obj, data): @@ -796,6 +844,27 @@ class JobTemplate(CommonModel): default=None, on_delete=models.SET_NULL, ) + use_sudo = models.NullBooleanField( + blank=True, + default=None, + ) + forks = models.PositiveIntegerField( + blank=True, + default=0, + ) + limit = models.CharField( + max_length=1024, + blank=True, + default='', + ) + verbosity = models.PositiveIntegerField( + blank=True, + default=0, + ) + extra_vars = JSONField( + blank=True, + default='', + ) def create_job(self, **kwargs): ''' @@ -811,6 +880,11 @@ class JobTemplate(CommonModel): kwargs.setdefault('project', self.project) kwargs.setdefault('playbook', self.playbook) kwargs.setdefault('credential', self.credential) + kwargs.setdefault('use_sudo', self.use_sudo) + kwargs.setdefault('forks', self.forks) + kwargs.setdefault('limit', self.limit) + kwargs.setdefault('verbosity', self.verbosity) + kwargs.setdefault('extra_vars', self.extra_vars) job = Job(**kwargs) if save_job: job.save() @@ -925,6 +999,7 @@ class Job(CommonModel): ('successful', _('Successful')), # Job completed successfully. ('failed', _('Failed')), # Job completed, but with failures. ('error', _('Error')), # The job was unable to run. + ('canceled', _('Canceled')), # The job was canceled before completion. ] class Meta: @@ -963,6 +1038,31 @@ class Job(CommonModel): playbook = models.CharField( max_length=1024, ) + use_sudo = models.NullBooleanField( + blank=True, + default=None, + ) + forks = models.PositiveIntegerField( + blank=True, + default=0, + ) + limit = models.CharField( + max_length=1024, + blank=True, + default='', + ) + verbosity = models.PositiveIntegerField( + blank=True, + default=0, + ) + extra_vars = JSONField( + blank=True, + default='', + ) + cancel_flag = models.BooleanField( + blank=True, + default=False, + ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, @@ -1006,17 +1106,29 @@ class Job(CommonModel): except TaskMeta.DoesNotExist: pass - def start(self): + def start(self, **kwargs): from lib.main.tasks import run_job if self.status != 'new': - return + return False + + #username = kwargs.get('username', self.username) + + opts = {} self.status = 'pending' self.save(update_fields=['status']) - task_result = run_job.delay(self.pk) + task_result = run_job.delay(self.pk, **opts) # The TaskMeta instance in the database isn't created until the worker # starts processing the task, so we can only store the task ID here. self.celery_task_id = task_result.task_id self.save(update_fields=['celery_task_id']) + return True + + def cancel(self): + if self.status in ('pending', 'running'): + if not self.cancel_flag: + self.cancel_flag = True + self.save(update_fields=['cancel_flag']) + return self.cancel_flag @property def successful_hosts(self): diff --git a/lib/main/tasks.py b/lib/main/tasks.py index 91cf47fd19..63435514f6 100644 --- a/lib/main/tasks.py +++ b/lib/main/tasks.py @@ -14,8 +14,11 @@ # You should have received a copy of the GNU General Public License # along with Ansible Commander. If not, see . +import logging import os +import select import subprocess +import time import traceback from celery import task from django.conf import settings @@ -23,8 +26,59 @@ from lib.main.models import * __all__ = ['run_job'] +logger = logging.getLogger('lib.tasks') + +class Timeout(object): + + def __init__(self, duration=None): + # If initializing from another instance, create a new timeout from the + # remaining time on the other instance. + if isinstance(duration, Timeout): + duration = duration.remaining + self.reset(duration) + + def __repr__(self): + if self._duration is None: + return 'Timeout(None)' + else: + return 'Timeout(%f)' % self._duration + + def __hash__(self): + return self._duration + + def __nonzero__(self): + return self.block + + def reset(self, duration=False): + if duration is not False: + self._duration = float(max(0, duration)) if duration is not None else None + self._begin = time.time() + + def expire(self): + self._begin = time.time() - max(0, self._duration or 0.0) + + @property + def duration(self): + return self._duration + + @property + def elapsed(self): + return float(max(0, time.time() - self._begin)) + + @property + def remaining(self): + if self._duration is None: + return None + else: + return float(max(0, self._duration + self._begin - time.time())) + + @property + def block(self): + return bool(self.remaining or self.remaining is None) + + @task(name='run_job') -def run_job(job_pk): +def run_job(job_pk, **kwargs): job = Job.objects.get(pk=job_pk) job.status = 'running' job.save(update_fields=['status']) @@ -50,17 +104,89 @@ def run_job(job_pk): if hasattr(settings, 'ANSIBLE_TRANSPORT'): env['ANSIBLE_TRANSPORT'] = getattr(settings, 'ANSIBLE_TRANSPORT') + creds = job.credential + username = creds.ssh_username + #sudo_username = job.credential.sudo_username + + + cwd = job.project.local_path + cmdline = ['ansible-playbook', '-i', inventory_script] if job.job_type == 'check': cmdline.append('--check') + if job.use_sudo: + cmdline.append('--sudo') + if job.forks: # FIXME: Max limit? + cmdline.append('--forks=%d' % job.forks) + if job.limit: + cmdline.append('--limit=%s' % job.limit) + if job.verbosity: + cmdline.append('-%s' % ('v' * min(3, job.verbosity))) + if job.extra_vars: + # FIXME: escaping! + extra_vars = ' '.join(['%s=%s' % (str(k), str(v)) for k,v in + job.extra_vars.items()]) + cmdline.append('-e', extra_vars) cmdline.append(job.playbook) # relative path to project.local_path - # FIXME: How to cancel/interrupt job? (not that important for now) proc = subprocess.Popen(cmdline, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd, env=env) - stdout, stderr = proc.communicate() - status = 'successful' if proc.returncode == 0 else 'failed' + # stdout, stderr = proc.communicate() + proc_canceled = False + while proc.returncode is None: + new_stdout, new_stderr = '', '' + timeout = Timeout(1.0) + while timeout: + # FIXME: Probably want to use poll (when on Linux), needs to be tested. + if hasattr(select, 'poll') and False: + poll = select.poll() + poll.register(proc.stdout.fileno(), select.POLLIN or select.POLLPRI) + poll.register(proc.stderr.fileno(), select.POLLIN or select.POLLPRI) + fd_events = poll.poll(1.0) + if not fd_events: + break + for fd, evt in fd_events: + if fd == proc.stdout.fileno() and evt > 0: + new_stdout += proc.stdout.read(1) + elif fd == proc.stderr.fileno() and evt > 0: + new_stderr += proc.stderr.read(1) + else: + stdout_byte, stderr_byte = '', '' + fdlist = [proc.stdout.fileno(), proc.stderr.fileno()] + rwx = select.select(fdlist, [], [], timeout.remaining) + if proc.stdout.fileno() in rwx[0]: + stdout_byte = proc.stdout.read(1) + new_stdout += stdout_byte + if proc.stderr.fileno() in rwx[0]: + stderr_byte = proc.stderr.read(1) + new_stderr += stderr_byte + if not stdout_byte and not stderr_byte: + break + job = Job.objects.get(pk=job_pk) + update_fields = [] + if new_stdout: + stdout += new_stdout + job.result_stdout = stdout + update_fields.append('result_stdout') + if new_stderr: + stderr += new_stderr + job.result_stderr = stderr + update_fields.append('result_stderr') + if update_fields: + job.save(update_fields=update_fields) + proc.poll() + if job.cancel_flag and not proc_canceled: + proc.terminate() + proc_canceled = True + stdout += proc.stdout.read() + stderr += proc.stderr.read() + if proc_canceled: + status = 'canceled' + elif proc.returncode == 0: + status = 'successful' + else: + status = 'failed' except Exception: tb = traceback.format_exc() diff --git a/lib/main/tests/tasks.py b/lib/main/tests/tasks.py index 03b067d0a5..2f3ed837f9 100644 --- a/lib/main/tests/tasks.py +++ b/lib/main/tests/tasks.py @@ -74,6 +74,20 @@ class RunJobTest(BaseCeleryTest): if self.test_project_path: shutil.rmtree(self.test_project_path, True) + def create_test_credential(self, **kwargs): + opts = { + 'name': 'test-creds', + 'user': self.super_django_user, + 'default_username': '', + 'ssh_key_data': '', + 'ssh_key_unlock': '', + 'ssh_password': '', + 'sudo_password': '', + } + opts.update(kwargs) + self.credential = Credential.objects.create(**opts) + return self.credential + def create_test_project(self, playbook_content): self.project = self.make_projects(self.normal_django_user, 1, playbook_content)[0] self.organization.projects.add(self.project) diff --git a/requirements.txt b/requirements.txt index 46c61b21c8..36c449094b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ django-devserver==0.5.0 django-extensions==1.1.1 django-jsonfield==0.9.2 ipython==0.13.1 +paramiko==1.10.0 # psycopg2==2.4.6 python-dateutil==1.5 PyYAML==3.10