diff options
-rw-r--r-- | MANIFEST.in | 1 | ||||
-rw-r--r-- | awx/main/access.py | 28 | ||||
-rw-r--r-- | awx/main/base_views.py | 1 | ||||
-rw-r--r-- | awx/main/migrations/0009_v13_changes.py | 53 | ||||
-rw-r--r-- | awx/main/models/__init__.py | 84 | ||||
-rw-r--r-- | awx/main/serializers.py | 31 | ||||
-rw-r--r-- | awx/main/tasks.py | 71 | ||||
-rw-r--r-- | awx/main/templates/main/api_view.md | 2 | ||||
-rw-r--r-- | awx/main/templates/main/list_api_view.md | 2 | ||||
-rw-r--r-- | awx/main/templates/main/list_create_api_view.md | 2 | ||||
-rw-r--r-- | awx/main/templates/main/retrieve_api_view.md | 3 | ||||
-rw-r--r-- | awx/main/templates/main/retrieve_update_destroy_api_view.md | 2 | ||||
-rw-r--r-- | awx/main/templates/main/sub_list_api_view.md | 2 | ||||
-rw-r--r-- | awx/main/templates/main/sub_list_create_api_view.md | 2 | ||||
-rw-r--r-- | awx/main/tests/projects.py | 79 | ||||
-rw-r--r-- | awx/main/urls.py | 42 | ||||
-rw-r--r-- | awx/main/views.py | 61 | ||||
-rw-r--r-- | awx/playbooks/project_update.yml | 13 |
18 files changed, 418 insertions, 61 deletions
diff --git a/MANIFEST.in b/MANIFEST.in index ca50af30eb..436537d7ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,6 +6,7 @@ recursive-include awx/ui *.html recursive-include awx/ui/static *.css *.ico *.png *.gif *.jpg recursive-include awx/ui/static *.eot *.svg *.ttf *.woff *.otf recursive-include awx/ui/static/lib * +recursive-include awx/playbooks *.yml recursive-include awx/lib/site-packages * recursive-include config * recursive-include config/deb * diff --git a/awx/main/access.py b/awx/main/access.py index 2ad3c77bc7..06aed49543 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -601,6 +601,33 @@ class ProjectAccess(BaseAccess): def can_delete(self, obj): return self.can_change(obj, None) +class ProjectUpdateAccess(BaseAccess): + ''' + I can see project updates when I can see the project. + I can change/delete when: + - I am a superuser. + - I am an admin in an organization associated with the project. + - I created it (for now?). + ''' + + model = ProjectUpdate + + def get_queryset(self): + qs = ProjectUpdate.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'project') + #if self.user.is_superuser: + return qs + #allowed = [PERM_INVENTORY_DEPLOY, PERM_INVENTORY_CHECK] + #return qs.filter( + # Q(created_by=self.user) | + # Q(organizations__admins__in=[self.user]) | + # Q(organizations__users__in=[self.user]) | + # Q(teams__users__in=[self.user]) | + # Q(permissions__user=self.user, permissions__permission_type__in=allowed) | + # Q(permissions__team__users__in=[self.user], permissions__permission_type__in=allowed) + #) + + class PermissionAccess(BaseAccess): ''' I can see a permission when: @@ -944,6 +971,7 @@ register_access(Group, GroupAccess) register_access(Credential, CredentialAccess) register_access(Team, TeamAccess) register_access(Project, ProjectAccess) +register_access(ProjectUpdate, ProjectUpdateAccess) register_access(Permission, PermissionAccess) register_access(JobTemplate, JobTemplateAccess) register_access(Job, JobAccess) diff --git a/awx/main/base_views.py b/awx/main/base_views.py index 9e621004b6..5d0b75d66a 100644 --- a/awx/main/base_views.py +++ b/awx/main/base_views.py @@ -34,6 +34,7 @@ class APIView(views.APIView): def get_description_context(self): return { 'docstring': type(self).__doc__ or '', + 'new_in_13': getattr(self, 'new_in_13', False), } def get_description(self, html=False): diff --git a/awx/main/migrations/0009_v13_changes.py b/awx/main/migrations/0009_v13_changes.py index bd1769a987..9fdf67f616 100644 --- a/awx/main/migrations/0009_v13_changes.py +++ b/awx/main/migrations/0009_v13_changes.py @@ -11,8 +11,10 @@ class Migration(SchemaMigration): # Adding model 'ProjectUpdate' db.create_table(u'main_projectupdate', ( (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('description', self.gf('django.db.models.fields.TextField')(default='', blank=True)), + ('created_by', self.gf('django.db.models.fields.related.ForeignKey')(related_name="{'class': 'projectupdate', 'app_label': 'main'}(class)s_created", null=True, on_delete=models.SET_NULL, to=orm['auth.User'])), ('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)), + ('active', self.gf('django.db.models.fields.BooleanField')(default=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)), @@ -46,6 +48,21 @@ class Migration(SchemaMigration): self.gf('django.db.models.fields.BooleanField')(default=False), keep_default=False) + # Adding field 'Project.scm_delete_on_update' + db.add_column(u'main_project', 'scm_delete_on_update', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'Project.scm_delete_on_next_update' + db.add_column(u'main_project', 'scm_delete_on_next_update', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding field 'Project.scm_update_on_launch' + db.add_column(u'main_project', 'scm_update_on_launch', + 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), @@ -66,6 +83,16 @@ class Migration(SchemaMigration): self.gf('django.db.models.fields.CharField')(default='', max_length=1024, null=True, blank=True), keep_default=False) + # Adding field 'Project.last_update' + db.add_column(u'main_project', 'last_update', + self.gf('django.db.models.fields.related.ForeignKey')(default=None, related_name='project_as_last_update+', null=True, to=orm['main.ProjectUpdate']), + keep_default=False) + + # Adding field 'Project.last_update_failed' + db.add_column(u'main_project', 'last_update_failed', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + def backwards(self, orm): # Deleting model 'ProjectUpdate' @@ -83,6 +110,15 @@ class Migration(SchemaMigration): # Deleting field 'Project.scm_clean' db.delete_column(u'main_project', 'scm_clean') + # Deleting field 'Project.scm_delete_on_update' + db.delete_column(u'main_project', 'scm_delete_on_update') + + # Deleting field 'Project.scm_delete_on_next_update' + db.delete_column(u'main_project', 'scm_delete_on_next_update') + + # Deleting field 'Project.scm_update_on_launch' + db.delete_column(u'main_project', 'scm_update_on_launch') + # Deleting field 'Project.scm_username' db.delete_column(u'main_project', 'scm_username') @@ -95,6 +131,12 @@ class Migration(SchemaMigration): # Deleting field 'Project.scm_key_unlock' db.delete_column(u'main_project', 'scm_key_unlock') + # Deleting field 'Project.last_update' + db.delete_column(u'main_project', 'last_update_id') + + # Deleting field 'Project.last_update_failed' + db.delete_column(u'main_project', 'last_update_failed') + models = { u'auth.group': { @@ -302,28 +344,35 @@ class Migration(SchemaMigration): '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'}), + 'last_update': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'project_as_last_update+'", 'null': 'True', 'to': "orm['main.ProjectUpdate']"}), + 'last_update_failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), '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_delete_on_next_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_update': ('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_update_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), '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'}, + '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\': \'projectupdate\', \'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'}), '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'}), diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index cf827dad04..98d03a41c4 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -94,7 +94,10 @@ class PrimordialModel(models.Model): tags = TaggableManager(blank=True) def __unicode__(self): - return unicode("%s-%s"% (self.name, self.id)) + if hasattr(self, 'name'): + return unicode("%s-%s"% (self.name, self.id)) + else: + return u'%s-%s' % (self._meta.verbose_name, self.id) def save(self, *args, **kwargs): # For compatibility with Django 1.4.x, attempt to handle any calls to @@ -520,6 +523,7 @@ class Project(CommonModel): help_text=_('Local path (relative to PROJECTS_ROOT) containing ' 'playbooks and related files for this project.') ) + scm_type = models.CharField( max_length=8, choices=SCM_TYPE_CHOICES, @@ -546,6 +550,16 @@ class Project(CommonModel): scm_clean = models.BooleanField( default=False, ) + scm_delete_on_update = models.BooleanField( + default=False, + ) + scm_delete_on_next_update = models.BooleanField( + default=False, + editable=True, + ) + scm_update_on_launch = models.BooleanField( + default=False, + ) scm_username = models.CharField( blank=True, null=True, @@ -578,14 +592,47 @@ class Project(CommonModel): help_text=_('Passphrase to unlock SSH private key if encrypted (or ' '"ASK" to prompt the user).'), ) + last_update = models.ForeignKey( + 'ProjectUpdate', + null=True, + default=None, + editable=False, + related_name='project_as_last_update+', + ) + last_update_failed = models.BooleanField( + default=False, + editable=False, + ) def save(self, *args, **kwargs): + # Check if scm_type or scm_url changes. + if self.pk: + project_before = Project.objects.get(pk=self.pk) + if project_before.scm_type != self.scm_type or project_before.scm_url != self.scm_url: + self.scm_delete_on_next_update = True 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']) + @property + def needs_scm_password(self): + return not self.scm_key_data and self.ssh_password == 'ASK' + + @property + def needs_scm_key_unlock(self): + return 'ENCRYPTED' in self.scm_key_data and \ + (not self.scm_key_unlock or self.scm_key_unlock == 'ASK') + + @property + def scm_passwords_needed(self): + needed = [] + for field in ('scm_password', 'scm_key_unlock'): + if getattr(self, 'needs_%s' % field): + needed.append(field) + return needed + def update(self): if self.scm_type: project_update = self.project_updates.create() @@ -593,11 +640,8 @@ class Project(CommonModel): return project_update @property - def last_update(self): - try: - return self.project_updates.order_by('-modified')[0] - except IndexError: - pass + def active_updates(self): + return self.project_updates.filter(active=True, status__in=('new', 'pending', 'running')) def get_absolute_url(self): return reverse('main:project_detail', args=(self.pk,)) @@ -641,20 +685,14 @@ class Project(CommonModel): results.append(playbook) return results -class ProjectUpdate(models.Model): +class ProjectUpdate(PrimordialModel): ''' - Job for tracking internal project updates. + Internal job for tracking project updates from SCM. ''' 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', @@ -711,8 +749,26 @@ class ProjectUpdate(models.Model): ) def save(self, *args, **kwargs): + # Get status before save... + status_before = self.status or 'new' + if self.pk: + project_update_before = ProjectUpdate.objects.get(pk=self.pk) + if project_update_before.status != self.status: + status_before = project_update_before.status self.failed = bool(self.status in ('failed', 'error', 'canceled')) super(ProjectUpdate, self).save(*args, **kwargs) + # If status changed, and update has completed, update project. + if self.status != status_before: + if self.status in ('successful', 'failed', 'error', 'canceled'): + project = self.project + project.last_update = self + project.last_update_failed = self.failed + if not self.failed and project.scm_delete_on_next_update: + project.scm_delete_on_next_update = False + project.save() + + def get_absolute_url(self): + return reverse('main:project_update_detail', args=(self.pk,)) @property def celery_task(self): diff --git a/awx/main/serializers.py b/awx/main/serializers.py index c3e2aec4bb..d08f6d20d4 100644 --- a/awx/main/serializers.py +++ b/awx/main/serializers.py @@ -175,11 +175,17 @@ class OrganizationSerializer(BaseSerializer): class ProjectSerializer(BaseSerializer): - playbooks = serializers.Field(source='playbooks', help_text='Array ') + playbooks = serializers.Field(source='playbooks', help_text='Array of playbooks available within this project.') + scm_delete_on_next_update = serializers.Field(source='scm_delete_on_next_update') class Meta: model = Project - fields = BASE_FIELDS + ('local_path',) + fields = BASE_FIELDS + ('local_path', 'scm_type', 'scm_url', + 'scm_branch', 'scm_clean', + 'scm_delete_on_update', 'scm_delete_on_next_update', + 'scm_update_on_launch', + 'scm_username', 'scm_password', 'scm_key_data', + 'scm_key_unlock', 'last_update_failed') def get_related(self, obj): res = super(ProjectSerializer, self).get_related(obj) @@ -187,7 +193,12 @@ class ProjectSerializer(BaseSerializer): organizations = reverse('main:project_organizations_list', args=(obj.pk,)), teams = reverse('main:project_teams_list', args=(obj.pk,)), playbooks = reverse('main:project_playbooks', args=(obj.pk,)), + update = reverse('main:project_update_view', args=(obj.pk,)), + project_updates = reverse('main:project_updates_list', args=(obj.pk,)), )) + if obj.last_update: + res['last_update'] = reverse('main:project_update_detail', + args=(obj.last_update.pk,)) return res def validate_local_path(self, attrs, source): @@ -209,6 +220,22 @@ class ProjectPlaybooksSerializer(ProjectSerializer): ret = super(ProjectPlaybooksSerializer, self).to_native(obj) return ret.get('playbooks', []) +class ProjectUpdateSerializer(BaseSerializer): + + class Meta: + model = ProjectUpdate + fields = ('id', 'url', 'related', 'summary_fields', 'created', + 'project', 'status', 'failed', 'result_stdout', + 'result_traceback', 'job_args', 'job_cwd', 'job_env') + + def get_related(self, obj): + res = super(ProjectUpdateSerializer, self).get_related(obj) + res.update(dict( + project = reverse('main:project_detail', args=(obj.project.pk,)), + cancel = reverse('main:project_update_cancel', args=(obj.pk,)), + )) + return res + class BaseSerializerWithVariables(BaseSerializer): def validate_variables(self, attrs, source): diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 72e474ec3e..756fa109bc 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -10,6 +10,7 @@ import subprocess import tempfile import time import traceback +import urlparse # Pexpect import pexpect @@ -110,7 +111,7 @@ class BaseTask(Task): r'Bad passphrase, try again for .*:': '', } - def run_pexpect(self, pk, args, cwd, env, passwords): + def run_pexpect(self, instance, args, cwd, env, passwords): ''' Run the given command using pexpect to capture output and provide passwords when requested. @@ -134,13 +135,15 @@ class BaseTask(Task): child.sendline(expect_passwords[result_id]) updates = {} if logfile_pos != logfile.tell(): + logfile_pos = logfile.tell() updates['result_stdout'] = logfile.getvalue() last_stdout_update = time.time() - instance = self.update_model(pk, **updates) + instance = self.update_model(instance.pk, **updates) if instance.cancel_flag: child.close(True) canceled = True - #elif (time.time() - last_stdout_update) > 30: # FIXME: Configurable idle timeout? + elif (time.time() - last_stdout_update) > 30: # FIXME: Configurable idle timeout? + print 'no updates...' # print 'canceling...' # child.close(True) # canceled = True @@ -153,10 +156,21 @@ class BaseTask(Task): stdout = logfile.getvalue() return status, stdout + def pre_run_check(self, instance, **kwargs): + ''' + Hook for checking job/task before running. + ''' + if instance.status != 'pending': + return False + return True + def run(self, pk, **kwargs): ''' Run the job/task using ansible-playbook and capture its output. ''' + instance = self.update_model(pk) + if not self.pre_run_check(instance, **kwargs): + return instance = self.update_model(pk, status='running') status, stdout, tb = 'error', '', '' try: @@ -167,7 +181,7 @@ class BaseTask(Task): 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, + status, stdout = self.run_pexpect(instance, args, cwd, env, kwargs['passwords']) except Exception: tb = traceback.format_exc() @@ -280,6 +294,15 @@ class RunJob(BaseTask): }) return d + def pre_run_check(self, job, **kwargs): + ''' + Hook for checking job before running. + ''' + if not super(RunJob, self).pre_run_check(job, **kwargs): + return False + # FIXME: Check if job is waiting on any projects that are being updated. + return True + class RunProjectUpdate(BaseTask): name = 'run_project_update' @@ -305,6 +328,19 @@ class RunProjectUpdate(BaseTask): env = super(RunProjectUpdate, self).build_env(project_update, **kwargs) return env + def update_url_auth(self, url, username=None, password=None): + parts = urlparse.urlsplit(url) + netloc_username = username or parts.username or '' + netloc_password = password or parts.password or '' + if netloc_username: + netloc = u':'.join(filter(None, [netloc_username, netloc_password])) + else: + netlock = u'' + netloc = u'@'.join(filter(None, [netloc, parts.hostname])) + netloc = u':'.join(filter(None, [netloc, parts.port])) + return urlparse.urlunsplit([parts.scheme, netloc, parts.path, + parts.query, parts.fragment]) + def build_args(self, project_update, **kwargs): ''' Build command line argument list for running ansible-playbook, @@ -312,16 +348,24 @@ class RunProjectUpdate(BaseTask): ''' args = ['ansible-playbook', '-i', 'localhost,'] args.append('-%s' % ('v' * 3)) - # FIXME project = project_update.project + scm_url = project.scm_url + if project.scm_username and project.scm_password: + scm_url = self.update_url_auth(scm_url, project.scm_username, project.scm_password) + elif project.scm_username: + scm_url = self.update_url_auth(scm_url, project.scm_username) + # FIXME: Need to hide password in saved job_args and result_stdout! + scm_branch = project.scm_branch or {'hg': 'tip'}.get(project.scm_type, 'HEAD') + scm_delete_on_update = project.scm_delete_on_update or project.scm_delete_on_next_update 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_url': scm_url, + 'scm_branch': scm_branch, 'scm_clean': project.scm_clean, - 'scm_username': project.scm_username, - 'scm_password': project.scm_password, + #'scm_username': project.scm_username, + #'scm_password': project.scm_password, + 'scm_delete_on_update': scm_delete_on_update, } args.extend(['-e', json.dumps(extra_vars)]) args.append('project_update.yml') @@ -348,3 +392,12 @@ class RunProjectUpdate(BaseTask): r'Are you sure you want to continue connecting (yes/no)\?': 'yes', }) return d + + def pre_run_check(self, project_update, **kwargs): + ''' + Hook for checking project update before running. + ''' + if not super(RunProjectUpdate, self).pre_run_check(project_update, **kwargs): + return False + # FIXME: Check if project update is blocked by any jobs that are being run. + return True diff --git a/awx/main/templates/main/api_view.md b/awx/main/templates/main/api_view.md index 1fb0d77840..65b67e4a05 100644 --- a/awx/main/templates/main/api_view.md +++ b/awx/main/templates/main/api_view.md @@ -1 +1,3 @@ {{ docstring }} + +{% if new_in_13 %}> _New in AWX 1.3_{% endif %} diff --git a/awx/main/templates/main/list_api_view.md b/awx/main/templates/main/list_api_view.md index 227bf5eada..90a3e8d529 100644 --- a/awx/main/templates/main/list_api_view.md +++ b/awx/main/templates/main/list_api_view.md @@ -4,3 +4,5 @@ Make a GET request to this resource to retrieve the list of {{ model_verbose_name_plural }}. {% include "main/_list_common.md" %} + +{% if new_in_13 %}> _New in AWX 1.3_{% endif %} diff --git a/awx/main/templates/main/list_create_api_view.md b/awx/main/templates/main/list_create_api_view.md index 7554fa983f..2e2ba03f9b 100644 --- a/awx/main/templates/main/list_create_api_view.md +++ b/awx/main/templates/main/list_create_api_view.md @@ -8,3 +8,5 @@ fields to create a new {{ model_verbose_name }}: {% with write_only=1 %} {% include "main/_result_fields_common.md" %} {% endwith %} + +{% if new_in_13 %}> _New in AWX 1.3_{% endif %} diff --git a/awx/main/templates/main/retrieve_api_view.md b/awx/main/templates/main/retrieve_api_view.md index 099b5865e3..79a1131f76 100644 --- a/awx/main/templates/main/retrieve_api_view.md +++ b/awx/main/templates/main/retrieve_api_view.md @@ -4,3 +4,6 @@ Make GET request to this resource to retrieve a single {{ model_verbose_name }} record containing the following fields: {% include "main/_result_fields_common.md" %} + +{% if new_in_13 %}> _New in AWX 1.3_{% endif %} + diff --git a/awx/main/templates/main/retrieve_update_destroy_api_view.md b/awx/main/templates/main/retrieve_update_destroy_api_view.md index 79afeeb141..1478d81d46 100644 --- a/awx/main/templates/main/retrieve_update_destroy_api_view.md +++ b/awx/main/templates/main/retrieve_update_destroy_api_view.md @@ -16,3 +16,5 @@ For a PATCH request, include only the fields that are being modified. # Delete {{ model_verbose_name|title }}: Make a DELETE request to this resource to delete this {{ model_verbose_name }}. + +{% if new_in_13 %}> _New in AWX 1.3_{% endif %} diff --git a/awx/main/templates/main/sub_list_api_view.md b/awx/main/templates/main/sub_list_api_view.md index ca99964000..bbbfbff036 100644 --- a/awx/main/templates/main/sub_list_api_view.md +++ b/awx/main/templates/main/sub_list_api_view.md @@ -5,3 +5,5 @@ Make a GET request to this resource to retrieve a list of {{ parent_model_verbose_name }}. {% include "main/_list_common.md" %} + +{% if new_in_13 %}> _New in AWX 1.3_{% endif %} diff --git a/awx/main/templates/main/sub_list_create_api_view.md b/awx/main/templates/main/sub_list_create_api_view.md index 9634ca6650..92f9764bc2 100644 --- a/awx/main/templates/main/sub_list_create_api_view.md +++ b/awx/main/templates/main/sub_list_create_api_view.md @@ -35,3 +35,5 @@ Make a POST request to this resource with `id` and `disassociate` fields to remove the {{ model_verbose_name }} from this {{ parent_model_verbose_name }} without deleting the {{ model_verbose_name }}. {% endif %} + +{% if new_in_13 %}> _New in AWX 1.3_{% endif %} diff --git a/awx/main/tests/projects.py b/awx/main/tests/projects.py index 9861420888..9eea9eabd9 100644 --- a/awx/main/tests/projects.py +++ b/awx/main/tests/projects.py @@ -621,7 +621,7 @@ class ProjectUpdatesTest(BaseTransactionTest): def setUp(self): super(ProjectUpdatesTest, self).setUp() self.setup_users() - self.skipTest('blah') + #self.skipTest('blah') def create_project(self, **kwargs): project = Project.objects.create(**kwargs) @@ -634,44 +634,103 @@ class ProjectUpdatesTest(BaseTransactionTest): def check_project_update(self, project, should_fail=False): - print project.local_path + #print project.local_path pu = project.update() self.assertTrue(pu) pu = ProjectUpdate.objects.get(pk=pu.pk) - print pu.status + #print pu.status + #print pu.result_traceback if should_fail: self.assertEqual(pu.status, 'failed', pu.result_stdout) else: self.assertEqual(pu.status, 'successful', pu.result_stdout) + project = Project.objects.get(pk=project.pk) + self.assertEqual(project.last_update, pu) + self.assertEqual(project.last_update_failed, pu.failed) #print pu.result_traceback #print pu.result_stdout #print + return pu 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('.'): + if f.startswith('.') or f == 'yadayada.txt': + continue + path_parts = os.path.relpath(root, project_path).split(os.sep) + if any([x.startswith('.') and x != '.' for x in path_parts]): continue path = os.path.join(root, f) + before = file(path, 'rb').read() + #print 'changed', path file(path, 'wb').write('CHANGED FILE') - return + after = file(path, 'rb').read() + return path, before, after self.fail('no file found to change!') def check_project_scm(self, project): + project_path = project.get_project_path(check_if_exists=False) # Initial checkout. + self.assertFalse(os.path.exists(project_path)) self.check_project_update(project) - # Update to existing checkout. + self.assertTrue(os.path.exists(project_path)) + # Stick a new untracked file in the project. + untracked_path = os.path.join(project_path, 'yadayada.txt') + self.assertFalse(os.path.exists(untracked_path)) + file(untracked_path, 'wb').write('yabba dabba doo') + self.assertTrue(os.path.exists(untracked_path)) + # Update to existing checkout (should leave untracked file alone). self.check_project_update(project) - # Change file then update (with scm_clean=False). + self.assertTrue(os.path.exists(untracked_path)) + # Change file then update (with scm_clean=False). Modified file should + # not be changed. 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. + modified_path, before, after = self.change_file_in_project(project) + # Mercurial still returns successful if a modified file is present. + should_fail = bool(project.scm_type != 'hg') + self.check_project_update(project, should_fail=should_fail) + content = file(modified_path, 'rb').read() + self.assertEqual(content, after) + self.assertTrue(os.path.exists(untracked_path)) + # Set scm_clean=True then try to update again. Modified file should + # have been replaced with the original. Untracked file should still be + # present. project.scm_clean = True project.save() self.check_project_update(project) + content = file(modified_path, 'rb').read() + self.assertEqual(content, before) + self.assertTrue(os.path.exists(untracked_path)) + # If scm_type or scm_url changes, scm_delete_on_next_update should be + # set, causing project directory (including untracked file) to be + # completely blown away, but only for the next update.. + self.assertFalse(project.scm_delete_on_update) + self.assertFalse(project.scm_delete_on_next_update) + scm_type = project.scm_type + project.scm_type = '' + project.save() + self.assertTrue(project.scm_delete_on_next_update) + project.scm_type = scm_type + project.save() + self.check_project_update(project) + self.assertFalse(os.path.exists(untracked_path)) + # Check that the flag is cleared after the update, and that an + # untracked file isn't blown away. + project = Project.objects.get(pk=project.pk) + self.assertFalse(project.scm_delete_on_next_update) + file(untracked_path, 'wb').write('yabba dabba doo') + self.assertTrue(os.path.exists(untracked_path)) + self.check_project_update(project) + self.assertTrue(os.path.exists(untracked_path)) + # Set scm_delete_on_update=True then update again. Project directory + # (including untracked file) should be completely blown away. + self.assertFalse(project.scm_delete_on_update) + project.scm_delete_on_update = True + project.save() + self.check_project_update(project) + self.assertFalse(os.path.exists(untracked_path)) def test_public_git_project_over_https(self): scm_url = getattr(settings, 'TEST_GIT_PUBLIC_HTTPS', diff --git a/awx/main/urls.py b/awx/main/urls.py index 07dd75cd60..c0c4b4c879 100644 --- a/awx/main/urls.py +++ b/awx/main/urls.py @@ -36,6 +36,13 @@ project_urls = patterns('awx.main.views', url(r'^(?P<pk>[0-9]+)/playbooks/$', 'project_playbooks'), url(r'^(?P<pk>[0-9]+)/organizations/$', 'project_organizations_list'), url(r'^(?P<pk>[0-9]+)/teams/$', 'project_teams_list'), + url(r'^(?P<pk>[0-9]+)/update/$', 'project_update_view'), + url(r'^(?P<pk>[0-9]+)/updates/$', 'project_updates_list'), +) + +project_update_urls = patterns('awx.main.views', + url(r'^(?P<pk>[0-9]+)/$', 'project_update_detail'), + url(r'^(?P<pk>[0-9]+)/cancel/$', 'project_update_cancel'), ) team_urls = patterns('awx.main.views', @@ -116,23 +123,24 @@ job_event_urls = patterns('awx.main.views', ) v1_urls = patterns('awx.main.views', - url(r'^$', 'api_v1_root_view'), - url(r'^config/$', 'api_v1_config_view'), - url(r'^authtoken/$', 'auth_token_view'), - url(r'^me/$', 'user_me_list'), - url(r'^organizations/', include(organization_urls)), - url(r'^users/', include(user_urls)), - url(r'^projects/', include(project_urls)), - url(r'^teams/', include(team_urls)), - url(r'^inventories/', include(inventory_urls)), - url(r'^hosts/', include(host_urls)), - url(r'^groups/', include(group_urls)), - url(r'^credentials/', include(credential_urls)), - url(r'^permissions/', include(permission_urls)), - url(r'^job_templates/', include(job_template_urls)), - url(r'^jobs/', include(job_urls)), - url(r'^job_host_summaries/', include(job_host_summary_urls)), - url(r'^job_events/', include(job_event_urls)), + url(r'^$', 'api_v1_root_view'), + url(r'^config/$', 'api_v1_config_view'), + url(r'^authtoken/$', 'auth_token_view'), + url(r'^me/$', 'user_me_list'), + url(r'^organizations/', include(organization_urls)), + url(r'^users/', include(user_urls)), + url(r'^projects/', include(project_urls)), + url(r'^project_updates/', include(project_update_urls)), + url(r'^teams/', include(team_urls)), + url(r'^inventories/', include(inventory_urls)), + url(r'^hosts/', include(host_urls)), + url(r'^groups/', include(group_urls)), + url(r'^credentials/', include(credential_urls)), + url(r'^permissions/', include(permission_urls)), + url(r'^job_templates/', include(job_template_urls)), + url(r'^jobs/', include(job_urls)), + url(r'^job_host_summaries/', include(job_host_summary_urls)), + url(r'^job_events/', include(job_event_urls)), ) urlpatterns = patterns('awx.main.views', diff --git a/awx/main/views.py b/awx/main/views.py index 4d07ded14e..3c91511d4e 100644 --- a/awx/main/views.py +++ b/awx/main/views.py @@ -251,6 +251,67 @@ class ProjectTeamsList(SubListCreateAPIView): parent_model = Project relationship = 'teams' +class ProjectUpdatesList(SubListAPIView): + + model = ProjectUpdate + serializer_class = ProjectUpdateSerializer + parent_model = Project + relationship = 'project_updates' + new_in_13 = True + +class ProjectUpdateView(GenericAPIView): + + model = Project + new_in_13 = True + + def get(self, request, *args, **kwargs): + obj = self.get_object() + data = dict( + can_update=bool(obj.scm_type), + ) + #if obj.scm_type: + # data['passwords_needed_to_update'] = obj.get_passwords_needed_to_start() + return Response(data) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + if bool(obj.scm_type): + project_update = obj.update() + if not project_update: + data = dict(msg='Unable to update project!') + return Response(data, status=status.HTTP_400_BAD_REQUEST) + else: + return Response(status=status.HTTP_202_ACCEPTED) + else: + return self.http_method_not_allowed(request, *args, **kwargs) + +class ProjectUpdateDetail(RetrieveAPIView): + + model = ProjectUpdate + serializer_class = ProjectUpdateSerializer + new_in_13 = True + +class ProjectUpdateCancel(GenericAPIView): + + model = ProjectUpdate + is_job_cancel = True + new_in_13 = True + + def get(self, request, *args, **kwargs): + obj = self.get_object() + data = dict( + can_cancel=obj.can_cancel, + ) + return Response(data) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + if obj.can_cancel: + result = obj.cancel() + return Response(status=status.HTTP_202_ACCEPTED) + else: + return self.http_method_not_allowed(request, *args, **kwargs) + class UserList(ListCreateAPIView): model = User diff --git a/awx/playbooks/project_update.yml b/awx/playbooks/project_update.yml index bb51aad3d0..f20d62a6a9 100644 --- a/awx/playbooks/project_update.yml +++ b/awx/playbooks/project_update.yml @@ -6,32 +6,31 @@ # scm_url: https://server/repo # scm_branch: HEAD # scm_clean: true/false +# scm_delete_on_update: true/false - hosts: all connection: local gather_facts: false tasks: - # Git Tasks + - name: delete project directory before update + file: path={{project_path}} state=absent + when: scm_delete_on_update + - 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}} + hg: dest={{project_path}} repo={{scm_url}} revision={{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 |