summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--MANIFEST.in1
-rw-r--r--awx/main/access.py28
-rw-r--r--awx/main/base_views.py1
-rw-r--r--awx/main/migrations/0009_v13_changes.py53
-rw-r--r--awx/main/models/__init__.py84
-rw-r--r--awx/main/serializers.py31
-rw-r--r--awx/main/tasks.py71
-rw-r--r--awx/main/templates/main/api_view.md2
-rw-r--r--awx/main/templates/main/list_api_view.md2
-rw-r--r--awx/main/templates/main/list_create_api_view.md2
-rw-r--r--awx/main/templates/main/retrieve_api_view.md3
-rw-r--r--awx/main/templates/main/retrieve_update_destroy_api_view.md2
-rw-r--r--awx/main/templates/main/sub_list_api_view.md2
-rw-r--r--awx/main/templates/main/sub_list_create_api_view.md2
-rw-r--r--awx/main/tests/projects.py79
-rw-r--r--awx/main/urls.py42
-rw-r--r--awx/main/views.py61
-rw-r--r--awx/playbooks/project_update.yml13
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