diff --git a/awx/main/admin.py b/awx/main/admin.py index aee02ded88..2c50c8935d 100644 --- a/awx/main/admin.py +++ b/awx/main/admin.py @@ -173,17 +173,30 @@ class TeamAdmin(BaseModelAdmin): list_display = ('name', 'description', 'active') filter_horizontal = ('projects', 'users') +class ProjectUpdateInline(admin.StackedInline): + + model = ProjectUpdate + extra = 0 + can_delete = True + fields = ('created', 'status', 'result_stdout') + readonly_fields = ('created', 'status', 'result_stdout') + + def has_add_permission(self, request): + return False + class ProjectAdmin(BaseModelAdmin): list_display = ('name', 'description', 'active') fieldsets = ( (None, {'fields': (('name', 'active'), 'description', 'local_path', 'get_playbooks_display')}), + (_('SCM'), {'fields': ('scm_type', 'scm_url', 'scm_branch')}), (_('Tags'), {'fields': ('tags',)}), (_('Audit'), {'fields': ('created', 'created_by',)}), ) readonly_fields = ('created', 'created_by', 'get_playbooks_display') form = ProjectAdminForm + inlines = [ProjectUpdateInline] def get_playbooks_display(self, obj): return '
'.join([format_html('{0}', x) for x in diff --git a/awx/main/migrations/0009_v13_changes.py b/awx/main/migrations/0009_v13_changes.py new file mode 100644 index 0000000000..bd1769a987 --- /dev/null +++ b/awx/main/migrations/0009_v13_changes.py @@ -0,0 +1,359 @@ +# -*- 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 model 'ProjectUpdate' + db.create_table(u'main_projectupdate', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + ('project', self.gf('django.db.models.fields.related.ForeignKey')(related_name='project_updates', to=orm['main.Project'])), + ('cancel_flag', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('status', self.gf('django.db.models.fields.CharField')(default='new', max_length=20)), + ('failed', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('job_args', self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True)), + ('job_cwd', self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True)), + ('job_env', self.gf('jsonfield.fields.JSONField')(default={}, blank=True)), + ('result_stdout', self.gf('django.db.models.fields.TextField')(default='', blank=True)), + ('result_traceback', self.gf('django.db.models.fields.TextField')(default='', blank=True)), + ('celery_task_id', self.gf('django.db.models.fields.CharField')(default='', max_length=100, blank=True)), + )) + db.send_create_signal('main', ['ProjectUpdate']) + + # Adding field 'Project.scm_type' + db.add_column(u'main_project', 'scm_type', + self.gf('django.db.models.fields.CharField')(default='', max_length=8, null=True, blank=True), + keep_default=False) + + # Adding field 'Project.scm_url' + db.add_column(u'main_project', 'scm_url', + self.gf('django.db.models.fields.URLField')(default='', max_length=1024, null=True, blank=True), + keep_default=False) + + # Adding field 'Project.scm_branch' + db.add_column(u'main_project', 'scm_branch', + self.gf('django.db.models.fields.CharField')(default='', max_length=256, null=True, blank=True), + keep_default=False) + + # Adding field 'Project.scm_clean' + db.add_column(u'main_project', 'scm_clean', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'Project.scm_username' + db.add_column(u'main_project', 'scm_username', + self.gf('django.db.models.fields.CharField')(default='', max_length=256, null=True, blank=True), + keep_default=False) + + # Adding field 'Project.scm_password' + db.add_column(u'main_project', 'scm_password', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, null=True, blank=True), + keep_default=False) + + # Adding field 'Project.scm_key_data' + db.add_column(u'main_project', 'scm_key_data', + self.gf('django.db.models.fields.TextField')(default='', null=True, blank=True), + keep_default=False) + + # Adding field 'Project.scm_key_unlock' + db.add_column(u'main_project', 'scm_key_unlock', + self.gf('django.db.models.fields.CharField')(default='', max_length=1024, null=True, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting model 'ProjectUpdate' + db.delete_table(u'main_projectupdate') + + # Deleting field 'Project.scm_type' + db.delete_column(u'main_project', 'scm_type') + + # Deleting field 'Project.scm_url' + db.delete_column(u'main_project', 'scm_url') + + # Deleting field 'Project.scm_branch' + db.delete_column(u'main_project', 'scm_branch') + + # Deleting field 'Project.scm_clean' + db.delete_column(u'main_project', 'scm_clean') + + # Deleting field 'Project.scm_username' + db.delete_column(u'main_project', 'scm_username') + + # Deleting field 'Project.scm_password' + db.delete_column(u'main_project', 'scm_password') + + # Deleting field 'Project.scm_key_data' + db.delete_column(u'main_project', 'scm_key_data') + + # Deleting field 'Project.scm_key_unlock' + db.delete_column(u'main_project', 'scm_key_unlock') + + + 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.credential': { + 'Meta': {'object_name': 'Credential'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + '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'}), + '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'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + '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']"}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.host': { + 'Meta': {'unique_together': "(('name', 'inventory'),)", 'object_name': 'Host'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts'", 'to': "orm['main.Inventory']"}), + 'last_job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts_as_last_job+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Job']", 'blank': 'True', 'null': 'True'}), + 'last_job_host_summary': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts_as_last_job_summary+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': u"orm['main.JobHostSummary']", 'blank': 'True', 'null': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.inventory': { + 'Meta': {'unique_together': "(('name', 'organization'),)", 'object_name': 'Inventory'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 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']"}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'null': 'True', 'blank': 'True'}) + }, + 'main.job': { + 'Meta': {'object_name': 'Job'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'cancel_flag': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'celery_task_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', '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']"}), + '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': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + '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_args': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_cwd': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_env': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'job_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + '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'}), + 'launch_type': ('django.db.models.fields.CharField', [], {'default': "'manual'", 'max_length': '20'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'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_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'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.jobevent': { + 'Meta': {'ordering': "('pk',)", 'object_name': 'JobEvent'}, + 'changed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + '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'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_events_as_primary_host'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Host']", 'blank': 'True', 'null': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'job_events'", 'blank': 'True', 'to': "orm['main.Host']"}), + 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']"}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'children'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobEvent']", 'blank': 'True', 'null': 'True'}), + 'play': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}) + }, + 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'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + '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'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + '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': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'host_config_key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', '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_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': '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', [], {'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']"}), + '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']"}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + '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']"}), + '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'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + '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']"}), + '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'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + '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.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'scm_branch': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'null': 'True', 'blank': 'True'}), + 'scm_clean': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_key_data': ('django.db.models.fields.TextField', [], {'default': "''", 'null': 'True', 'blank': 'True'}), + 'scm_key_unlock': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'null': 'True', 'blank': 'True'}), + 'scm_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'null': 'True', 'blank': 'True'}), + 'scm_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'scm_url': ('django.db.models.fields.URLField', [], {'default': "''", 'max_length': '1024', 'null': 'True', 'blank': 'True'}), + 'scm_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'null': 'True', 'blank': 'True'}) + }, + 'main.projectupdate': { + 'Meta': {'object_name': 'ProjectUpdate'}, + 'cancel_flag': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'celery_task_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job_args': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_cwd': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_env': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'project_updates'", 'to': u"orm['main.Project']"}), + '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'}) + }, + 'main.team': { + 'Meta': {'object_name': 'Team'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + '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']"}), + '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']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + u'taggit.tag': { + 'Meta': {'object_name': 'Tag'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '100'}), + 'slug': ('django.db.models.fields.SlugField', [], {'unique': 'True', 'max_length': '100'}) + }, + u'taggit.taggeditem': { + 'Meta': {'object_name': 'TaggedItem'}, + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_tagged_items'", 'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'object_id': ('django.db.models.fields.IntegerField', [], {'db_index': 'True'}), + 'tag': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "u'taggit_taggeditem_items'", 'to': u"orm['taggit.Tag']"}) + } + } + + complete_apps = ['main'] \ No newline at end of file diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 28e6849727..e4036edd58 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -19,6 +19,7 @@ from django.utils.translation import ugettext_lazy as _ from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.utils.timezone import now +from django.utils.text import slugify # Django-JSONField from jsonfield import JSONField @@ -29,11 +30,12 @@ from taggit.managers import TaggableManager # Django-Celery from djcelery.models import TaskMeta -__all__ = ['PrimordialModel', 'Organization', 'Team', 'Project', 'Credential', - 'Inventory', 'Host', 'Group', 'Permission', 'JobTemplate', 'Job', - 'JobHostSummary', 'JobEvent', 'PERM_INVENTORY_ADMIN', - 'PERM_INVENTORY_READ', 'PERM_INVENTORY_WRITE', - 'PERM_INVENTORY_DEPLOY', 'PERM_INVENTORY_CHECK'] +__all__ = ['PrimordialModel', 'Organization', 'Team', 'Project', + 'ProjectUpdate', 'Credential', 'Inventory', 'Host', 'Group', + 'Permission', 'JobTemplate', 'Job', 'JobHostSummary', 'JobEvent', + 'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ', + 'PERM_INVENTORY_WRITE', 'PERM_INVENTORY_DEPLOY', + 'PERM_INVENTORY_CHECK', 'JOB_STATUS_CHOICES'] logger = logging.getLogger('awx.main.models') @@ -59,6 +61,16 @@ PERMISSION_TYPE_CHOICES = [ (PERM_INVENTORY_CHECK, _('Deploy To Inventory (Dry Run)')), ] +JOB_STATUS_CHOICES = [ + ('new', _('New')), # Job has been created, but not started. + ('pending', _('Pending')), # Job has been queued, but is not yet running. + ('running', _('Running')), # Job is currently running. + ('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 PrimordialModel(models.Model): ''' common model for all object types that have these standard fields @@ -470,6 +482,13 @@ class Project(CommonModel): A project represents a playbook git repo that can access a set of inventories ''' + SCM_TYPE_CHOICES = [ + ('', _('Manual')), + ('git', _('Git')), + ('hg', _('Mercurial')), + ('svn', _('Subversion')), + ] + # this is not part of the project, but managed with perms # inventories = models.ManyToManyField('Inventory', blank=True, related_name='projects') @@ -483,7 +502,7 @@ class Project(CommonModel): if os.path.exists(settings.PROJECTS_ROOT): paths = [x for x in os.listdir(settings.PROJECTS_ROOT) if os.path.isdir(os.path.join(settings.PROJECTS_ROOT, x)) - and not x.startswith('.')] + and not x.startswith('.') and not x.startswith('_')] qs = Project.objects.filter(active=True) used_paths = qs.values_list('local_path', flat=True) return [x for x in paths if x not in used_paths] @@ -495,20 +514,97 @@ class Project(CommonModel): # Not unique for now, otherwise "deletes" won't allow reusing the # same path for another active project. #unique=True, + blank=True, help_text=_('Local path (relative to PROJECTS_ROOT) containing ' 'playbooks and related files for this project.') ) - #scm_type = models.CharField(max_length=64) - #default_playbook = models.CharField(max_length=1024) + scm_type = models.CharField( + max_length=8, + choices=SCM_TYPE_CHOICES, + blank=True, + null=True, + default='', + verbose_name=_('SCM Type'), + ) + scm_url = models.URLField( + max_length=1024, + blank=True, + null=True, + default='', + verbose_name=_('SCM URL'), + ) + scm_branch = models.CharField( + max_length=256, + blank=True, + null=True, + default='', + verbose_name=_('SCM Branch'), + help_text=_('Specific branch, tag or commit to checkout.'), + ) + scm_clean = models.BooleanField( + default=False, + ) + scm_username = models.CharField( + blank=True, + null=True, + default='', + max_length=256, + verbose_name=_('Username'), + help_text=_('SCM username for this project.'), + ) + scm_password = models.CharField( + blank=True, + null=True, + default='', + max_length=1024, + verbose_name=_('Password'), + help_text=_('SCM password (or "ASK" to prompt the user).'), + ) + scm_key_data = models.TextField( + blank=True, + null=True, + default='', + verbose_name=_('SSH private key'), + help_text=_('RSA or DSA private key to be used instead of password.'), + ) + scm_key_unlock = models.CharField( + max_length=1024, + null=True, + blank=True, + default='', + verbose_name=_('SSH key unlock'), + help_text=_('Passphrase to unlock SSH private key if encrypted (or ' + '"ASK" to prompt the user).'), + ) + + def save(self, *args, **kwargs): + super(Project, self).save(*args, **kwargs) + if self.scm_type and not self.local_path.startswith('_'): + slug_name = slugify(unicode(self.name)).replace(u'-', u'_') + self.local_path = u'_%d__%s' % (self.pk, slug_name) + self.save(update_fields=['local_path']) + + def update(self): + if self.scm_type: + project_update = self.project_updates.create() + project_update.start() + return project_update + + @property + def last_update(self): + try: + return self.project_updates.order_by('-modified')[0] + except IndexError: + pass def get_absolute_url(self): return reverse('main:project_detail', args=(self.pk,)) - def get_project_path(self): + def get_project_path(self, check_if_exists=True): local_path = os.path.basename(self.local_path) if local_path and not local_path.startswith('.'): proj_path = os.path.join(settings.PROJECTS_ROOT, local_path) - if os.path.exists(proj_path): + if not check_if_exists or os.path.exists(proj_path): return proj_path @property @@ -543,6 +639,118 @@ class Project(CommonModel): results.append(playbook) return results +class ProjectUpdate(models.Model): + ''' + Job for tracking internal project updates. + ''' + + class Meta: + app_label = 'main' + + created = models.DateTimeField( + auto_now_add=True, + ) + modified = models.DateTimeField( + auto_now=True, + ) + project = models.ForeignKey( + 'Project', + related_name='project_updates', + on_delete=models.CASCADE, + editable=False, + ) + cancel_flag = models.BooleanField( + blank=True, + default=False, + editable=False, + ) + status = models.CharField( + max_length=20, + choices=JOB_STATUS_CHOICES, + default='new', + editable=False, + ) + failed = models.BooleanField( + default=False, + editable=False, + ) + job_args = models.CharField( + max_length=1024, + blank=True, + default='', + editable=False, + ) + job_cwd = models.CharField( + max_length=1024, + blank=True, + default='', + editable=False, + ) + job_env = JSONField( + blank=True, + default={}, + editable=False, + ) + result_stdout = models.TextField( + blank=True, + default='', + editable=False, + ) + result_traceback = models.TextField( + blank=True, + default='', + editable=False, + ) + celery_task_id = models.CharField( + max_length=100, + blank=True, + default='', + editable=False, + ) + + def save(self, *args, **kwargs): + self.failed = bool(self.status in ('failed', 'error', 'canceled')) + super(ProjectUpdate, self).save(*args, **kwargs) + + @property + def celery_task(self): + try: + if self.celery_task_id: + return TaskMeta.objects.get(task_id=self.celery_task_id) + except TaskMeta.DoesNotExist: + pass + + @property + def can_start(self): + return bool(self.status == 'new') + + def start(self, **kwargs): + from awx.main.tasks import RunProjectUpdate + if not self.can_start: + return False + self.status = 'pending' + self.save(update_fields=['status']) + task_result = RunProjectUpdate().delay(self.pk, **kwargs) + # Reload project update from database so we don't clobber results + # from RunProjectUpdate (mainly from tests when using Django 1.4.x). + project_update = ProjectUpdate.objects.get(pk=self.pk) + # 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. + project_update.celery_task_id = task_result.task_id + project_update.save(update_fields=['celery_task_id']) + return True + + @property + def can_cancel(self): + return bool(self.status in ('pending', 'running')) + + def cancel(self): + if self.can_cancel: + if not self.cancel_flag: + self.cancel_flag = True + self.save(update_fields=['cancel_flag']) + return self.cancel_flag + class Permission(CommonModelNameNotUnique): ''' A permission allows a user, project, or team to be able to use an inventory source. @@ -702,16 +910,6 @@ class Job(CommonModelNameNotUnique): ('scheduled', _('Scheduled')), ] - STATUS_CHOICES = [ - ('new', _('New')), # Job has been created, but not started. - ('pending', _('Pending')), # Job has been queued, but is not yet running. - ('running', _('Running')), # Job is currently running. - ('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: app_label = 'main' @@ -783,7 +981,7 @@ class Job(CommonModelNameNotUnique): ) status = models.CharField( max_length=20, - choices=STATUS_CHOICES, + choices=JOB_STATUS_CHOICES, default='new', editable=False, ) diff --git a/awx/main/serializers.py b/awx/main/serializers.py index af05643dea..c3e2aec4bb 100644 --- a/awx/main/serializers.py +++ b/awx/main/serializers.py @@ -195,7 +195,7 @@ class ProjectSerializer(BaseSerializer): valid_local_paths = Project.get_local_path_choices() if self.object: valid_local_paths.append(self.object.local_path) - if attrs[source] not in valid_local_paths: + if source in attrs and attrs[source] not in valid_local_paths: raise serializers.ValidationError('Invalid path choice') return attrs diff --git a/awx/main/tasks.py b/awx/main/tasks.py index a566d29d7d..72e474ec3e 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -8,6 +8,7 @@ import logging import os import subprocess import tempfile +import time import traceback # Pexpect @@ -20,33 +21,31 @@ from celery import Task from django.conf import settings # AWX -from awx.main.models import Job +from awx.main.models import Job, ProjectUpdate -__all__ = ['RunJob'] +__all__ = ['RunJob', 'RunProjectUpdate'] logger = logging.getLogger('awx.main.tasks') -class RunJob(Task): - ''' - Celery task to run a job using ansible-playbook. - ''' +class BaseTask(Task): + + name = None + model = None - name = 'run_job' - - def update_job(self, job_pk, **job_updates): + def update_model(self, pk, **updates): ''' - Reload Job from database and update the given fields. + Reload model from database and update the given fields. ''' - job = Job.objects.get(pk=job_pk) - if job_updates: + instance = self.model.objects.get(pk=pk) + if updates: update_fields = [] - for field, value in job_updates.items(): - setattr(job, field, value) + for field, value in updates.items(): + setattr(instance, field, value) update_fields.append(field) if field == 'status': update_fields.append('failed') - job.save(update_fields=update_fields) - return job + instance.save(update_fields=update_fields) + return instance def get_path_to(self, *args): ''' @@ -54,21 +53,141 @@ class RunJob(Task): ''' return os.path.abspath(os.path.join(os.path.dirname(__file__), *args)) - def build_ssh_key_path(self, job, **kwargs): + def build_ssh_key_path(self, instance, **kwargs): ''' Create a temporary file containing the SSH private key. ''' - creds = job.credential - if creds and creds.ssh_key_data: + ssh_key_data = getattr(instance, 'ssh_key_data', '') + if not ssh_key_data: + credential = getattr(instance, 'credential', None) + ssh_key_data = getattr(credential, 'ssh_key_data', '') + if ssh_key_data: # FIXME: File permissions? handle, path = tempfile.mkstemp() f = os.fdopen(handle, 'w') - f.write(creds.ssh_key_data) + f.write(ssh_key_data) f.close() return path else: return '' + def build_passwords(self, instance, **kwargs): + ''' + Build a dictionary of passwords responding to prompts. + ''' + return {} + + def build_env(self, instance, **kwargs): + ''' + Build environment dictionary for ansible-playbook. + ''' + env = dict(os.environ.items()) + # Add ANSIBLE_* settings to the subprocess environment. + for attr in dir(settings): + if attr == attr.upper() and attr.startswith('ANSIBLE_'): + env[attr] = str(getattr(settings, attr)) + # Also set environment variables configured in AWX_TASK_ENV setting. + for key, value in settings.AWX_TASK_ENV.items(): + env[key] = str(value) + # Set environment variables needed for inventory and job event + # callbacks to work. + env['ANSIBLE_NOCOLOR'] = '1' # Prevent output of escape sequences. + return env + + def build_args(self, instance, **kwargs): + raise NotImplementedError + + def build_cwd(self, instance, **kwargs): + raise NotImplementedError + + def get_password_prompts(self): + ''' + Return a dictionary of prompt regular expressions and password lookup + keys. + ''' + return { + r'Enter passphrase for .*:': 'ssh_key_unlock', + r'Bad passphrase, try again for .*:': '', + } + + def run_pexpect(self, pk, args, cwd, env, passwords): + ''' + Run the given command using pexpect to capture output and provide + passwords when requested. + ''' + status, stdout = 'error', '' + logfile = cStringIO.StringIO() + logfile_pos = logfile.tell() + child = pexpect.spawn(args[0], args[1:], cwd=cwd, env=env) + child.logfile_read = logfile + canceled = False + last_stdout_update = time.time() + expect_list = [] + expect_passwords = {} + for n, item in enumerate(self.get_password_prompts().items()): + expect_list.append(item[0]) + expect_passwords[n] = passwords.get(item[1], '') + expect_list.extend([pexpect.TIMEOUT, pexpect.EOF]) + while child.isalive(): + result_id = child.expect(expect_list, timeout=2) + if result_id in expect_passwords: + child.sendline(expect_passwords[result_id]) + updates = {} + if logfile_pos != logfile.tell(): + updates['result_stdout'] = logfile.getvalue() + last_stdout_update = time.time() + instance = self.update_model(pk, **updates) + if instance.cancel_flag: + child.close(True) + canceled = True + #elif (time.time() - last_stdout_update) > 30: # FIXME: Configurable idle timeout? + # print 'canceling...' + # child.close(True) + # canceled = True + if canceled: + status = 'canceled' + elif child.exitstatus == 0: + status = 'successful' + else: + status = 'failed' + stdout = logfile.getvalue() + return status, stdout + + def run(self, pk, **kwargs): + ''' + Run the job/task using ansible-playbook and capture its output. + ''' + instance = self.update_model(pk, status='running') + status, stdout, tb = 'error', '', '' + try: + kwargs['ssh_key_path'] = self.build_ssh_key_path(instance, **kwargs) + kwargs['passwords'] = self.build_passwords(instance, **kwargs) + args = self.build_args(instance, **kwargs) + cwd = self.build_cwd(instance, **kwargs) + env = self.build_env(instance, **kwargs) + instance = self.update_model(pk, job_args=json.dumps(args), + job_cwd=cwd, job_env=env) + status, stdout = self.run_pexpect(pk, args, cwd, env, + kwargs['passwords']) + except Exception: + tb = traceback.format_exc() + finally: + if kwargs.get('ssh_key_path', ''): + try: + os.remove(kwargs['ssh_key_path']) + except IOError: + pass + self.update_model(pk, status=status, result_stdout=stdout, + result_traceback=tb) + +class RunJob(BaseTask): + ''' + Celery task to run a job using ansible-playbook. + ''' + + name = 'run_job' + model = Job + def build_passwords(self, job, **kwargs): ''' Build a dictionary of passwords for SSH private key, SSH user and sudo. @@ -87,22 +206,12 @@ class RunJob(Task): Build environment dictionary for ansible-playbook. ''' plugin_dir = self.get_path_to('..', 'plugins', 'callback') - env = dict(os.environ.items()) - # question: when running over CLI, generate a random ID or grab next, etc? - # answer: TBD - # Add ANSIBLE_* settings to the subprocess environment. - for attr in dir(settings): - if attr == attr.upper() and attr.startswith('ANSIBLE_'): - env[attr] = str(getattr(settings, attr)) - # Also set environment variables configured in AWX_TASK_ENV setting. - for key, value in settings.AWX_TASK_ENV.items(): - env[key] = str(value) + env = super(RunJob, self).build_env(job, **kwargs) # Set environment variables needed for inventory and job event # callbacks to work. env['JOB_ID'] = str(job.pk) env['INVENTORY_ID'] = str(job.inventory.pk) env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_dir - env['ANSIBLE_NOCOLOR'] = '1' # Prevent output of escape sequences. env['REST_API_URL'] = settings.INTERNAL_API_URL env['REST_API_TOKEN'] = job.task_auth_token or '' return env @@ -154,79 +263,88 @@ class RunJob(Task): args = ['ssh-agent', 'sh', '-c', cmd] return args - def run_pexpect(self, job_pk, args, cwd, env, passwords): - ''' - Run the job using pexpect to capture output and provide passwords when - requested. - ''' - status, stdout = 'error', '' - logfile = cStringIO.StringIO() - logfile_pos = logfile.tell() - child = pexpect.spawn(args[0], args[1:], cwd=cwd, env=env) - child.logfile_read = logfile - job_canceled = False - while child.isalive(): - expect_list = [ - r'Enter passphrase for .*:', - r'Bad passphrase, try again for .*:', - r'sudo password.*:', - r'SSH password:', - r'Password:', - pexpect.TIMEOUT, - pexpect.EOF, - ] - result_id = child.expect(expect_list, timeout=2) - if result_id == 0: - child.sendline(passwords.get('ssh_key_unlock', '')) - elif result_id == 1: - child.sendline('') - elif result_id == 2: - child.sendline(passwords.get('sudo_password', '')) - elif result_id in (3, 4): - child.sendline(passwords.get('ssh_password', '')) - job_updates = {} - if logfile_pos != logfile.tell(): - job_updates['result_stdout'] = logfile.getvalue() - job = self.update_job(job_pk, **job_updates) - if job.cancel_flag: - child.close(True) - job_canceled = True - if job_canceled: - status = 'canceled' - elif child.exitstatus == 0: - status = 'successful' - else: - status = 'failed' - stdout = logfile.getvalue() - return status, stdout - - def run(self, job_pk, **kwargs): - ''' - Run the job using ansible-playbook and capture its output. - ''' - job = self.update_job(job_pk, status='running') - status, stdout, tb = 'error', '', '' - try: - kwargs['ssh_key_path'] = self.build_ssh_key_path(job, **kwargs) - kwargs['passwords'] = self.build_passwords(job, **kwargs) - args = self.build_args(job, **kwargs) - cwd = job.project.get_project_path() + def build_cwd(self, job, **kwargs): + cwd = job.project.get_project_path() + if not cwd: root = settings.PROJECTS_ROOT - if not cwd: - raise RuntimeError('project local_path %s cannot be found in %s' % - (job.project.local_path, root)) - env = self.build_env(job, **kwargs) - job = self.update_job(job_pk, job_args=json.dumps(args), - job_cwd=cwd, job_env=env) - status, stdout = self.run_pexpect(job_pk, args, cwd, env, - kwargs['passwords']) - except Exception: - tb = traceback.format_exc() - finally: - if kwargs.get('ssh_key_path', ''): - try: - os.remove(kwargs['ssh_key_path']) - except IOError: - pass - self.update_job(job_pk, status=status, result_stdout=stdout, - result_traceback=tb) + raise RuntimeError('project local_path %s cannot be found in %s' % + (job.project.local_path, root)) + return cwd + + def get_password_prompts(self): + d = super(RunJob, self).get_password_prompts() + d.update({ + r'sudo password.*:': 'sudo_password', + r'SSH password:': 'ssh_password', + r'Password:': 'ssh_password', + }) + return d + +class RunProjectUpdate(BaseTask): + + name = 'run_project_update' + model = ProjectUpdate + + def build_passwords(self, project_update, **kwargs): + ''' + Build a dictionary of passwords for SSH private key. + ''' + passwords = {} + project = project_update.project + value = project.scm_key_unlock + if value not in ('', 'ASK'): + passwords['ssh_key_unlock'] = value + passwords['scm_username'] = project.scm_username + passwords['scm_password'] = project.scm_password + return passwords + + def build_env(self, project_update, **kwargs): + ''' + Build environment dictionary for ansible-playbook. + ''' + env = super(RunProjectUpdate, self).build_env(project_update, **kwargs) + return env + + def build_args(self, project_update, **kwargs): + ''' + Build command line argument list for running ansible-playbook, + optionally using ssh-agent for public/private key authentication. + ''' + args = ['ansible-playbook', '-i', 'localhost,'] + args.append('-%s' % ('v' * 3)) + # FIXME + project = project_update.project + extra_vars = { + 'project_path': project.get_project_path(check_if_exists=False), + 'scm_type': project.scm_type, + 'scm_url': project.scm_url, + 'scm_branch': project.scm_branch or 'HEAD', + 'scm_clean': project.scm_clean, + 'scm_username': project.scm_username, + 'scm_password': project.scm_password, + } + args.extend(['-e', json.dumps(extra_vars)]) + args.append('project_update.yml') + + ssh_key_path = kwargs.get('ssh_key_path', '') + subcmds = [ + ('ssh-add', '-D'), + args, + ] + if ssh_key_path: + subcmds.insert(1, ('ssh-add', ssh_key_path)) + cmd = ' && '.join([subprocess.list2cmdline(x) for x in subcmds]) + args = ['ssh-agent', 'sh', '-c', cmd] + return args + + def build_cwd(self, project_update, **kwargs): + return self.get_path_to('..', 'playbooks') + + def get_password_prompts(self): + d = super(RunProjectUpdate, self).get_password_prompts() + d.update({ + r'Username for.*:': 'scm_username', + r'Password for.*:': 'scm_password', + r'Are you sure you want to continue connecting (yes/no)\?': 'yes', + }) + return d diff --git a/awx/main/tests/__init__.py b/awx/main/tests/__init__.py index 9da6477668..985bda16e1 100644 --- a/awx/main/tests/__init__.py +++ b/awx/main/tests/__init__.py @@ -4,7 +4,7 @@ from awx.main.tests.organizations import OrganizationsTest from awx.main.tests.users import UsersTest from awx.main.tests.inventory import InventoryTest -from awx.main.tests.projects import ProjectsTest +from awx.main.tests.projects import ProjectsTest, ProjectUpdatesTest from awx.main.tests.commands import * from awx.main.tests.scripts import * from awx.main.tests.tasks import RunJobTest diff --git a/awx/main/tests/jobs.py b/awx/main/tests/jobs.py index 40e550be72..93d0d867f5 100644 --- a/awx/main/tests/jobs.py +++ b/awx/main/tests/jobs.py @@ -777,7 +777,7 @@ class JobStartCancelTest(BaseJobTestMixin, django.test.LiveServerTestCase): # Sue can start a job (when passwords are already saved) as long as the # status is new. Reverse list so "new" will be last. - for status in reversed([x[0] for x in Job.STATUS_CHOICES]): + for status in reversed([x[0] for x in JOB_STATUS_CHOICES]): job.status = status job.save() with self.current_user(self.user_sue): @@ -863,7 +863,7 @@ class JobStartCancelTest(BaseJobTestMixin, django.test.LiveServerTestCase): self.check_invalid_auth(url, methods=('post',)) # sue can cancel the job, but only when it is pending or running. - for status in [x[0] for x in Job.STATUS_CHOICES]: + for status in [x[0] for x in JOB_STATUS_CHOICES]: job.status = status job.save() with self.current_user(self.user_sue): diff --git a/awx/main/tests/projects.py b/awx/main/tests/projects.py index 3695b75773..9861420888 100644 --- a/awx/main/tests/projects.py +++ b/awx/main/tests/projects.py @@ -1,19 +1,24 @@ # Copyright (c) 2013 AnsibleWorks, Inc. # All Rights Reserved. +# Python import datetime import json import os import tempfile +import urlparse +# Django from django.conf import settings from django.contrib.auth.models import User import django.test from django.test.client import Client from django.core.urlresolvers import reverse +from django.test.utils import override_settings +# AWX from awx.main.models import * -from awx.main.tests.base import BaseTest +from awx.main.tests.base import BaseTest, BaseTransactionTest TEST_PLAYBOOK = '''- hosts: mygroup gather_facts: false @@ -607,3 +612,170 @@ class ProjectsTest(BaseTest): self.delete(url2, expect=403, auth=self.get_other_credentials()) self.delete(url2, expect=204, auth=self.get_super_credentials()) self.delete(url2, expect=404, auth=self.get_other_credentials()) + +@override_settings(CELERY_ALWAYS_EAGER=True, + CELERY_EAGER_PROPAGATES_EXCEPTIONS=True, + ANSIBLE_TRANSPORT='local') +class ProjectUpdatesTest(BaseTransactionTest): + + def setUp(self): + super(ProjectUpdatesTest, self).setUp() + self.setup_users() + self.skipTest('blah') + + def create_project(self, **kwargs): + project = Project.objects.create(**kwargs) + project_path = project.get_project_path(check_if_exists=False) + self._temp_project_dirs.append(project_path) + return project + + def update_url_auth(self, url, username=None, password=None): + parts = urlparse.urlsplit(url) + + + def check_project_update(self, project, should_fail=False): + print project.local_path + pu = project.update() + self.assertTrue(pu) + pu = ProjectUpdate.objects.get(pk=pu.pk) + print pu.status + if should_fail: + self.assertEqual(pu.status, 'failed', pu.result_stdout) + else: + self.assertEqual(pu.status, 'successful', pu.result_stdout) + #print pu.result_traceback + #print pu.result_stdout + #print + + def change_file_in_project(self, project): + project_path = project.get_project_path() + self.assertTrue(project_path) + for root, dirs, files in os.walk(project_path): + for f in files: + if f.startswith('.'): + continue + path = os.path.join(root, f) + file(path, 'wb').write('CHANGED FILE') + return + self.fail('no file found to change!') + + def check_project_scm(self, project): + # Initial checkout. + self.check_project_update(project) + # Update to existing checkout. + self.check_project_update(project) + # Change file then update (with scm_clean=False). + self.assertFalse(project.scm_clean) + self.change_file_in_project(project) + self.check_project_update(project, should_fail=True) + # Set scm_clean=True then try to update again. + project.scm_clean = True + project.save() + self.check_project_update(project) + + def test_public_git_project_over_https(self): + scm_url = getattr(settings, 'TEST_GIT_PUBLIC_HTTPS', + 'https://github.com/ansible/ansible.github.com.git') + if not all([scm_url]): + self.skipTest('no public git repo defined for https!') + project = self.create_project( + name='my public git project over https', + scm_type='git', + scm_url=scm_url, + ) + self.check_project_scm(project) + + def test_private_git_project_over_https(self): + scm_url = getattr(settings, 'TEST_GIT_PRIVATE_HTTPS', '') + scm_username = getattr(settings, 'TEST_GIT_USERNAME', '') + scm_password = getattr(settings, 'TEST_GIT_PASSWORD', '') + if not all([scm_url, scm_username, scm_password]): + self.skipTest('no private git repo defined for https!') + project = self.create_project( + name='my private git project over https', + scm_type='git', + scm_url=scm_url, + scm_username=scm_username, + scm_password=scm_password, + ) + self.check_project_scm(project) + + def test_private_git_project_over_ssh(self): + scm_url = getattr(settings, 'TEST_GIT_PRIVATE_SSH', '') + scm_key_data = getattr(settings, 'TEST_GIT_KEY_DATA', '') + if not all([scm_url, scm_key_data]): + self.skipTest('no private git repo defined for ssh!') + project = self.create_project( + name='my private git project over ssh', + scm_type='git', + scm_url=scm_url, + scm_key_data=scm_key_data, + ) + self.check_project_scm(project) + + def test_public_hg_project_over_https(self): + scm_url = getattr(settings, 'TEST_HG_PUBLIC_HTTPS', + 'https://bitbucket.org/cchurch/django-hotrunner') + if not all([scm_url]): + self.skipTest('no public hg repo defined for https!') + project = self.create_project( + name='my public hg project over https', + scm_type='hg', + scm_url=scm_url, + ) + self.check_project_scm(project) + + def test_private_hg_project_over_https(self): + scm_url = getattr(settings, 'TEST_HG_PRIVATE_HTTPS', '') + scm_username = getattr(settings, 'TEST_HG_USERNAME', '') + scm_password = getattr(settings, 'TEST_HG_PASSWORD', '') + if not all([scm_url, scm_username, scm_password]): + self.skipTest('no private hg repo defined for https!') + project = self.create_project( + name='my private hg project over https', + scm_type='hg', + scm_url=scm_url, + scm_username=scm_username, + scm_password=scm_password, + ) + self.check_project_scm(project) + + def test_private_hg_project_over_ssh(self): + scm_url = getattr(settings, 'TEST_HG_PRIVATE_SSH', '') + scm_key_data = getattr(settings, 'TEST_HG_KEY_DATA', '') + if not all([scm_url, scm_key_data]): + self.skipTest('no private hg repo defined for ssh!') + project = self.create_project( + name='my private hg project over ssh', + scm_type='hg', + scm_url=scm_url, + scm_key_data=scm_key_data, + ) + self.check_project_scm(project) + + def test_public_svn_project_over_https(self): + scm_url = getattr(settings, 'TEST_SVN_PUBLIC_HTTPS', + 'https://projects.ninemoreminutes.com/svn/django-site-utils/') + if not all([scm_url]): + self.skipTest('no public svn repo defined for https!') + project = self.create_project( + name='my public svn project over https', + scm_type='svn', + scm_url=scm_url, + ) + self.check_project_scm(project) + + def test_private_svn_project_over_https(self): + scm_url = getattr(settings, 'TEST_SVN_PRIVATE_HTTPS', '') + scm_username = getattr(settings, 'TEST_SVN_USERNAME', '') + scm_password = getattr(settings, 'TEST_SVN_PASSWORD', '') + if not all([scm_url, scm_username, scm_password]): + self.skipTest('no private svn repo defined for https!') + project = self.create_project( + name='my private svn project over https', + scm_type='svn', + scm_url=scm_url, + scm_username=scm_username, + scm_password=scm_password, + ) + self.check_project_scm(project) diff --git a/awx/playbooks/project_update.yml b/awx/playbooks/project_update.yml new file mode 100644 index 0000000000..bb51aad3d0 --- /dev/null +++ b/awx/playbooks/project_update.yml @@ -0,0 +1,37 @@ +--- + +# The following variables will be set by the runner of this playbook: +# project_path: PROJECTS_DIR/_local_path_ +# scm_type: git|hg|svn +# scm_url: https://server/repo +# scm_branch: HEAD +# scm_clean: true/false + +- hosts: all + connection: local + gather_facts: false + tasks: + + # Git Tasks + - name: update project using git + git: dest={{project_path}} repo={{scm_url}} version={{scm_branch}} force={{scm_clean}} + when: scm_type == 'git' + async: 0 + poll: 5 + tags: git + + # Mercurial Tasks + - name: update project using hg + hg: dest={{project_path}} repo={{scm_url}} version={{scm_branch}} force={{scm_clean}} + when: scm_type == 'hg' + async: 0 + poll: 5 + tags: hg + + # Subversion Tasks + - name: update project using svn + subversion: dest={{project_path}} repo={{scm_url}} revision={{scm_branch}} force={{scm_clean}} + when: scm_type == 'svn' + async: 0 + poll: 5 + tags: svn diff --git a/awx/settings/local_settings.py.example b/awx/settings/local_settings.py.example index 2528bbaeec..17ed53e53b 100644 --- a/awx/settings/local_settings.py.example +++ b/awx/settings/local_settings.py.example @@ -115,3 +115,29 @@ LOGGING['handlers']['syslog'] = { # the celery task. #AWX_TASK_ENV['FOO'] = 'BAR' +# Define these variables to enable more complete testing of project support for +# SCM updates. +try: + path = os.path.expanduser(os.path.expandvars('~/.ssh/id_rsa.pub')) + TEST_SSH_KEY_DATA = file(path, 'rb').read() +except OSError: + TEST_SSH_KEY_DATA = '' + +TEST_GIT_USERNAME = '' +TEST_GIT_PASSWORD = '' +TEST_GIT_KEY_DATA = TEST_SSH_KEY_DATA +TEST_GIT_PUBLIC_HTTPS = 'https://github.com/ansible/ansible-examples.git' +TEST_GIT_PRIVATE_HTTPS = 'https://github.com/ansible/ansible-doc.git' +TEST_GIT_PRIVATE_SSH = 'git@github.com:ansible/ansible-doc.git' + +TEST_HG_USERNAME = '' +TEST_HG_PASSWORD = '' +TEST_HG_KEY_DATA = TEST_SSH_KEY_DATA +TEST_HG_PUBLIC_HTTPS = 'https://bitbucket.org/cchurch/django-hotrunner' +TEST_HG_PRIVATE_HTTPS = '' +TEST_HG_PRIVATE_SSH = '' + +TEST_SVN_USERNAME = '' +TEST_SVN_PASSWORD = '' +TEST_SVN_PUBLIC_HTTPS = 'https://projects.ninemoreminutes.com/svn/django-site-utils/trunk/' +TEST_SVN_PRIVATE_HTTPS = ''