Merge remote-tracking branch 'downstream/release_3.3.0' into devel

# Conflicts:
#	awx/main/notifications/slack_backend.py
This commit is contained in:
Shane McDonald
2018-09-18 08:11:50 -04:00
246 changed files with 10831 additions and 6593 deletions

View File

@@ -611,7 +611,8 @@ class OAuth2ApplicationAccess(BaseAccess):
select_related = ('user',)
def filtered_queryset(self):
return self.model.objects.filter(organization__in=self.user.organizations)
org_access_qs = Organization.accessible_objects(self.user, 'member_role')
return self.model.objects.filter(organization__in=org_access_qs)
def can_change(self, obj, data):
return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj,

View File

@@ -117,10 +117,10 @@ class IsolatedManager(object):
@classmethod
def awx_playbook_path(cls):
return os.path.join(
return os.path.abspath(os.path.join(
os.path.dirname(awx.__file__),
'playbooks'
)
))
def path_to(self, *args):
return os.path.join(self.private_data_dir, *args)

View File

@@ -208,6 +208,12 @@ def run_isolated_job(private_data_dir, secrets, logfile=sys.stdout):
env['AWX_ISOLATED_DATA_DIR'] = private_data_dir
env['PYTHONPATH'] = env.get('PYTHONPATH', '') + callback_dir + ':'
venv_path = env.get('VIRTUAL_ENV')
if venv_path and not os.path.exists(venv_path):
raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path)
)
return run_pexpect(args, cwd, env, logfile,
expect_passwords=expect_passwords,
idle_timeout=idle_timeout,

View File

@@ -0,0 +1,12 @@
from django.db import connections
from django.db.backends.sqlite3.base import DatabaseWrapper
from django.core.management.commands.makemigrations import Command as MakeMigrations
class Command(MakeMigrations):
def execute(self, *args, **options):
settings = connections['default'].settings_dict.copy()
settings['ENGINE'] = 'sqlite3'
connections['default'] = DatabaseWrapper(settings)
return MakeMigrations().execute(*args, **options)

View File

@@ -491,7 +491,7 @@ class Command(BaseCommand):
for host in hosts_qs.filter(pk__in=del_pks):
host_name = host.name
host.delete()
logger.info('Deleted host "%s"', host_name)
logger.debug('Deleted host "%s"', host_name)
if settings.SQL_DEBUG:
logger.warning('host deletions took %d queries for %d hosts',
len(connection.queries) - queries_before,
@@ -528,7 +528,7 @@ class Command(BaseCommand):
group_name = group.name
with ignore_inventory_computed_fields():
group.delete()
logger.info('Group "%s" deleted', group_name)
logger.debug('Group "%s" deleted', group_name)
if settings.SQL_DEBUG:
logger.warning('group deletions took %d queries for %d groups',
len(connection.queries) - queries_before,
@@ -549,7 +549,7 @@ class Command(BaseCommand):
db_groups = self.inventory_source.groups
for db_group in db_groups.all():
if self.inventory_source.deprecated_group_id == db_group.id: # TODO: remove in 3.3
logger.info(
logger.debug(
'Group "%s" from v1 API child group/host connections preserved',
db_group.name
)
@@ -566,8 +566,8 @@ class Command(BaseCommand):
for db_child in db_children.filter(pk__in=child_group_pks):
group_group_count += 1
db_group.children.remove(db_child)
logger.info('Group "%s" removed from group "%s"',
db_child.name, db_group.name)
logger.debug('Group "%s" removed from group "%s"',
db_child.name, db_group.name)
# FIXME: Inventory source group relationships
# Delete group/host relationships not present in imported data.
db_hosts = db_group.hosts
@@ -594,8 +594,8 @@ class Command(BaseCommand):
if db_host not in db_group.hosts.all():
continue
db_group.hosts.remove(db_host)
logger.info('Host "%s" removed from group "%s"',
db_host.name, db_group.name)
logger.debug('Host "%s" removed from group "%s"',
db_host.name, db_group.name)
if settings.SQL_DEBUG:
logger.warning('group-group and group-host deletions took %d queries for %d relationships',
len(connection.queries) - queries_before,
@@ -614,9 +614,9 @@ class Command(BaseCommand):
if db_variables != all_obj.variables_dict:
all_obj.variables = json.dumps(db_variables)
all_obj.save(update_fields=['variables'])
logger.info('Inventory variables updated from "all" group')
logger.debug('Inventory variables updated from "all" group')
else:
logger.info('Inventory variables unmodified')
logger.debug('Inventory variables unmodified')
def _create_update_groups(self):
'''
@@ -648,11 +648,11 @@ class Command(BaseCommand):
group.variables = json.dumps(db_variables)
group.save(update_fields=['variables'])
if self.overwrite_vars:
logger.info('Group "%s" variables replaced', group.name)
logger.debug('Group "%s" variables replaced', group.name)
else:
logger.info('Group "%s" variables updated', group.name)
logger.debug('Group "%s" variables updated', group.name)
else:
logger.info('Group "%s" variables unmodified', group.name)
logger.debug('Group "%s" variables unmodified', group.name)
existing_group_names.add(group.name)
self._batch_add_m2m(self.inventory_source.groups, group)
for group_name in all_group_names:
@@ -666,7 +666,7 @@ class Command(BaseCommand):
'description':'imported'
}
)[0]
logger.info('Group "%s" added', group.name)
logger.debug('Group "%s" added', group.name)
self._batch_add_m2m(self.inventory_source.groups, group)
self._batch_add_m2m(self.inventory_source.groups, flush=True)
if settings.SQL_DEBUG:
@@ -705,24 +705,24 @@ class Command(BaseCommand):
if update_fields:
db_host.save(update_fields=update_fields)
if 'name' in update_fields:
logger.info('Host renamed from "%s" to "%s"', old_name, mem_host.name)
logger.debug('Host renamed from "%s" to "%s"', old_name, mem_host.name)
if 'instance_id' in update_fields:
if old_instance_id:
logger.info('Host "%s" instance_id updated', mem_host.name)
logger.debug('Host "%s" instance_id updated', mem_host.name)
else:
logger.info('Host "%s" instance_id added', mem_host.name)
logger.debug('Host "%s" instance_id added', mem_host.name)
if 'variables' in update_fields:
if self.overwrite_vars:
logger.info('Host "%s" variables replaced', mem_host.name)
logger.debug('Host "%s" variables replaced', mem_host.name)
else:
logger.info('Host "%s" variables updated', mem_host.name)
logger.debug('Host "%s" variables updated', mem_host.name)
else:
logger.info('Host "%s" variables unmodified', mem_host.name)
logger.debug('Host "%s" variables unmodified', mem_host.name)
if 'enabled' in update_fields:
if enabled:
logger.info('Host "%s" is now enabled', mem_host.name)
logger.debug('Host "%s" is now enabled', mem_host.name)
else:
logger.info('Host "%s" is now disabled', mem_host.name)
logger.debug('Host "%s" is now disabled', mem_host.name)
self._batch_add_m2m(self.inventory_source.hosts, db_host)
def _create_update_hosts(self):
@@ -796,9 +796,9 @@ class Command(BaseCommand):
host_attrs['instance_id'] = instance_id
db_host = self.inventory.hosts.update_or_create(name=mem_host_name, defaults=host_attrs)[0]
if enabled is False:
logger.info('Host "%s" added (disabled)', mem_host_name)
logger.debug('Host "%s" added (disabled)', mem_host_name)
else:
logger.info('Host "%s" added', mem_host_name)
logger.debug('Host "%s" added', mem_host_name)
self._batch_add_m2m(self.inventory_source.hosts, db_host)
self._batch_add_m2m(self.inventory_source.hosts, flush=True)
@@ -827,10 +827,10 @@ class Command(BaseCommand):
child_names = all_child_names[offset2:(offset2 + self._batch_size)]
db_children_qs = self.inventory.groups.filter(name__in=child_names)
for db_child in db_children_qs.filter(children__id=db_group.id):
logger.info('Group "%s" already child of group "%s"', db_child.name, db_group.name)
logger.debug('Group "%s" already child of group "%s"', db_child.name, db_group.name)
for db_child in db_children_qs.exclude(children__id=db_group.id):
self._batch_add_m2m(db_group.children, db_child)
logger.info('Group "%s" added as child of "%s"', db_child.name, db_group.name)
logger.debug('Group "%s" added as child of "%s"', db_child.name, db_group.name)
self._batch_add_m2m(db_group.children, flush=True)
if settings.SQL_DEBUG:
logger.warning('Group-group updates took %d queries for %d group-group relationships',
@@ -854,19 +854,19 @@ class Command(BaseCommand):
host_names = all_host_names[offset2:(offset2 + self._batch_size)]
db_hosts_qs = self.inventory.hosts.filter(name__in=host_names)
for db_host in db_hosts_qs.filter(groups__id=db_group.id):
logger.info('Host "%s" already in group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" already in group "%s"', db_host.name, db_group.name)
for db_host in db_hosts_qs.exclude(groups__id=db_group.id):
self._batch_add_m2m(db_group.hosts, db_host)
logger.info('Host "%s" added to group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" added to group "%s"', db_host.name, db_group.name)
all_instance_ids = sorted([h.instance_id for h in mem_group.hosts if h.instance_id])
for offset2 in xrange(0, len(all_instance_ids), self._batch_size):
instance_ids = all_instance_ids[offset2:(offset2 + self._batch_size)]
db_hosts_qs = self.inventory.hosts.filter(instance_id__in=instance_ids)
for db_host in db_hosts_qs.filter(groups__id=db_group.id):
logger.info('Host "%s" already in group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" already in group "%s"', db_host.name, db_group.name)
for db_host in db_hosts_qs.exclude(groups__id=db_group.id):
self._batch_add_m2m(db_group.hosts, db_host)
logger.info('Host "%s" added to group "%s"', db_host.name, db_group.name)
logger.debug('Host "%s" added to group "%s"', db_host.name, db_group.name)
self._batch_add_m2m(db_group.hosts, flush=True)
if settings.SQL_DEBUG:
logger.warning('Group-host updates took %d queries for %d group-host relationships',

View File

@@ -6,6 +6,22 @@ from django.core.management.base import BaseCommand
import six
class Ungrouped(object):
name = 'ungrouped'
policy_instance_percentage = None
policy_instance_minimum = None
controller = None
@property
def instances(self):
return Instance.objects.filter(rampart_groups__isnull=True)
@property
def capacity(self):
return sum([x.capacity for x in self.instances])
class Command(BaseCommand):
"""List instances from the Tower database
"""
@@ -13,12 +29,28 @@ class Command(BaseCommand):
def handle(self, *args, **options):
super(Command, self).__init__()
for instance in Instance.objects.all():
print(six.text_type(
"hostname: {0.hostname}; created: {0.created}; "
"heartbeat: {0.modified}; capacity: {0.capacity}").format(instance))
for instance_group in InstanceGroup.objects.all():
print(six.text_type(
"Instance Group: {0.name}; created: {0.created}; "
"capacity: {0.capacity}; members: {1}").format(instance_group,
[x.hostname for x in instance_group.instances.all()]))
groups = list(InstanceGroup.objects.all())
ungrouped = Ungrouped()
if len(ungrouped.instances):
groups.append(ungrouped)
for instance_group in groups:
fmt = '[{0.name} capacity={0.capacity}'
if instance_group.policy_instance_percentage:
fmt += ' policy={0.policy_instance_percentage}%'
if instance_group.policy_instance_minimum:
fmt += ' policy>={0.policy_instance_minimum}'
if instance_group.controller:
fmt += ' controller={0.controller.name}'
print(six.text_type(fmt + ']').format(instance_group))
for x in instance_group.instances.all():
color = '\033[92m'
if x.capacity == 0 or x.enabled is False:
color = '\033[91m'
fmt = '\t' + color + '{0.hostname} capacity={0.capacity} version={1}'
if x.last_isolated_check:
fmt += ' last_isolated_check="{0.last_isolated_check:%Y-%m-%d %H:%M:%S}"'
if x.capacity:
fmt += ' heartbeat="{0.modified:%Y-%m-%d %H:%M:%S}"'
print(six.text_type(fmt + '\033[0m').format(x, x.version or '?'))
print('')

View File

@@ -95,7 +95,7 @@ class ReplayJobEvents():
raise RuntimeError("Job is of type {} and replay is not yet supported.".format(type(job)))
sys.exit(1)
def run(self, job_id, speed=1.0, verbosity=0, skip=0):
def run(self, job_id, speed=1.0, verbosity=0, skip_range=[]):
stats = {
'events_ontime': {
'total': 0,
@@ -127,7 +127,7 @@ class ReplayJobEvents():
je_previous = None
for n, je_current in enumerate(job_events):
if n < skip:
if je_current.counter in skip_range:
continue
if not je_previous:
@@ -193,19 +193,29 @@ class Command(BaseCommand):
help = 'Replay job events over websockets ordered by created on date.'
def _parse_slice_range(self, slice_arg):
slice_arg = tuple([int(n) for n in slice_arg.split(':')])
slice_obj = slice(*slice_arg)
start = slice_obj.start or 0
stop = slice_obj.stop or -1
step = slice_obj.step or 1
return range(start, stop, step)
def add_arguments(self, parser):
parser.add_argument('--job_id', dest='job_id', type=int, metavar='j',
help='Id of the job to replay (job or adhoc)')
parser.add_argument('--speed', dest='speed', type=int, metavar='s',
help='Speedup factor.')
parser.add_argument('--skip', dest='skip', type=int, metavar='k',
help='Number of events to skip.')
parser.add_argument('--skip-range', dest='skip_range', type=str, metavar='k',
default='0:-1:1', help='Range of events to skip')
def handle(self, *args, **options):
job_id = options.get('job_id')
speed = options.get('speed') or 1
verbosity = options.get('verbosity') or 0
skip = options.get('skip') or 0
skip = self._parse_slice_range(options.get('skip_range'))
replayer = ReplayJobEvents()
replayer.run(job_id, speed, verbosity, skip)

View File

@@ -64,15 +64,22 @@ class CallbackBrokerWorker(ConsumerMixin):
return _handler
if use_workers:
django_connection.close()
django_cache.close()
for idx in range(settings.JOB_EVENT_WORKERS):
queue_actual = MPQueue(settings.JOB_EVENT_MAX_QUEUE_SIZE)
w = Process(target=self.callback_worker, args=(queue_actual, idx,))
w.start()
if settings.DEBUG:
logger.info('Started worker %s' % str(idx))
logger.info('Starting worker %s' % str(idx))
self.worker_queues.append([0, queue_actual, w])
# It's important to close these _right before_ we fork; we
# don't want the forked processes to inherit the open sockets
# for the DB and memcached connections (that way lies race
# conditions)
django_connection.close()
django_cache.close()
for _, _, w in self.worker_queues:
w.start()
elif settings.DEBUG:
logger.warn('Started callback receiver (no workers)')

View File

@@ -1,6 +1,8 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import base64
import json
import logging
import threading
import uuid
@@ -9,12 +11,15 @@ import time
import cProfile
import pstats
import os
import re
from django.conf import settings
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.db.models.signals import post_save
from django.db.migrations.executor import MigrationExecutor
from django.db import IntegrityError, connection
from django.http import HttpResponse
from django.utils.functional import curry
from django.shortcuts import get_object_or_404, redirect
from django.apps import apps
@@ -128,8 +133,9 @@ class SessionTimeoutMiddleware(object):
def process_response(self, request, response):
req_session = getattr(request, 'session', None)
if req_session and not req_session.is_empty():
request.session.set_expiry(request.session.get_expiry_age())
response['Session-Timeout'] = int(settings.SESSION_COOKIE_AGE)
expiry = int(settings.SESSION_COOKIE_AGE)
request.session.set_expiry(expiry)
response['Session-Timeout'] = expiry
return response
@@ -203,6 +209,56 @@ class URLModificationMiddleware(object):
request.path_info = new_path
class DeprecatedAuthTokenMiddleware(object):
"""
Used to emulate support for the old Auth Token endpoint to ease the
transition to OAuth2.0. Specifically, this middleware:
1. Intercepts POST requests to `/api/v2/authtoken/` (which now no longer
_actually_ exists in our urls.py)
2. Rewrites `request.path` to `/api/v2/users/N/personal_tokens/`
3. Detects the username and password in the request body (either in JSON,
or form-encoded variables) and builds an appropriate HTTP_AUTHORIZATION
Basic header
"""
def process_request(self, request):
if re.match('^/api/v[12]/authtoken/?$', request.path):
if request.method != 'POST':
return HttpResponse('HTTP {} is not allowed.'.format(request.method), status=405)
try:
payload = json.loads(request.body)
except (ValueError, TypeError):
payload = request.POST
if 'username' not in payload or 'password' not in payload:
return HttpResponse('Unable to login with provided credentials.', status=401)
username = payload['username']
password = payload['password']
try:
pk = User.objects.get(username=username).pk
except ObjectDoesNotExist:
return HttpResponse('Unable to login with provided credentials.', status=401)
new_path = reverse('api:user_personal_token_list', kwargs={
'pk': pk,
'version': 'v2'
})
request._body = ''
request.META['CONTENT_TYPE'] = 'application/json'
request.path = request.path_info = new_path
auth = ' '.join([
'Basic',
base64.b64encode(
six.text_type('{}:{}').format(username, password)
)
])
request.environ['HTTP_AUTHORIZATION'] = auth
logger.warn(
'The Auth Token API (/api/v2/authtoken/) is deprecated and will '
'be replaced with OAuth2.0 in the next version of Ansible Tower '
'(see /api/o/ for more details).'
)
class MigrationRanCheckMiddleware(object):
def process_request(self, request):

View File

@@ -157,7 +157,7 @@ class Migration(migrations.Migration):
('status', models.CharField(default=b'pending', max_length=20, editable=False, choices=[(b'pending', 'Pending'), (b'successful', 'Successful'), (b'failed', 'Failed')])),
('error', models.TextField(default=b'', editable=False, blank=True)),
('notifications_sent', models.IntegerField(default=0, editable=False)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'irc', 'IRC')])),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'rocketchat', 'Rocket.Chat'), (b'irc', 'IRC')])),
('recipients', models.TextField(default=b'', editable=False, blank=True)),
('subject', models.TextField(default=b'', editable=False, blank=True)),
('body', jsonfield.fields.JSONField(default=dict, blank=True)),
@@ -174,7 +174,7 @@ class Migration(migrations.Migration):
('modified', models.DateTimeField(default=None, editable=False)),
('description', models.TextField(default=b'', blank=True)),
('name', models.CharField(unique=True, max_length=512)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'irc', 'IRC')])),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'mattermost', 'Mattermost'), (b'rocketchat', 'Rocket.Chat'), (b'irc', 'IRC')])),
('notification_configuration', jsonfield.fields.JSONField(default=dict)),
('created_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),
('modified_by', models.ForeignKey(related_name="{u'class': 'notificationtemplate', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),

View File

@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)),
('inventory_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.InventoryUpdate')),
('inventory_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_update_events', to='main.InventoryUpdate')),
],
options={
'ordering': ('-pk',),
@@ -53,7 +53,7 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)),
('project_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.ProjectUpdate')),
('project_update', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='project_update_events', to='main.ProjectUpdate')),
],
options={
'ordering': ('pk',),
@@ -72,12 +72,24 @@ class Migration(migrations.Migration):
('verbosity', models.PositiveIntegerField(default=0, editable=False)),
('start_line', models.PositiveIntegerField(default=0, editable=False)),
('end_line', models.PositiveIntegerField(default=0, editable=False)),
('system_job', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='generic_command_events', to='main.SystemJob')),
('system_job', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.CASCADE, related_name='system_job_events', to='main.SystemJob')),
],
options={
'ordering': ('-pk',),
},
),
migrations.AlterIndexTogether(
name='inventoryupdateevent',
index_together=set([('inventory_update', 'start_line'), ('inventory_update', 'uuid'), ('inventory_update', 'end_line')]),
),
migrations.AlterIndexTogether(
name='projectupdateevent',
index_together=set([('project_update', 'event'), ('project_update', 'end_line'), ('project_update', 'start_line'), ('project_update', 'uuid')]),
),
migrations.AlterIndexTogether(
name='systemjobevent',
index_together=set([('system_job', 'end_line'), ('system_job', 'uuid'), ('system_job', 'start_line')]),
),
migrations.RemoveField(
model_name='unifiedjob',
name='result_stdout_file',

View File

@@ -64,12 +64,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='activitystream',
name='o_auth2_access_token',
field=models.ManyToManyField(to='main.OAuth2AccessToken', blank=True, related_name='main_o_auth2_accesstoken'),
field=models.ManyToManyField(to='main.OAuth2AccessToken', blank=True),
),
migrations.AddField(
model_name='activitystream',
name='o_auth2_application',
field=models.ManyToManyField(to='main.OAuth2Application', blank=True, related_name='main_o_auth2_application'),
field=models.ManyToManyField(to='main.OAuth2Application', blank=True),
),
]

View File

@@ -16,6 +16,6 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='oauth2accesstoken',
name='scope',
field=models.TextField(blank=True, help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."),
field=models.TextField(blank=True, default=b'write', help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."),
),
]

View File

@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='oauth2accesstoken',
name='modified',
field=models.DateTimeField(editable=False),
field=models.DateTimeField(editable=False, auto_now=True),
),
]

View File

@@ -0,0 +1,147 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-08-16 16:46
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('main', '0047_v330_activitystream_instance'),
]
operations = [
migrations.AlterField(
model_name='credential',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credential', u'model_name': 'credential'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credential',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credential', u'model_name': 'credential'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credentialtype',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credentialtype', u'model_name': 'credentialtype'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='credentialtype',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'credentialtype', u'model_name': 'credentialtype'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='custominventoryscript',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'custominventoryscript', u'model_name': 'custominventoryscript'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='custominventoryscript',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'custominventoryscript', u'model_name': 'custominventoryscript'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='group',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'group', u'model_name': 'group'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='group',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'group', u'model_name': 'group'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='host',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'host', u'model_name': 'host'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='host',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'host', u'model_name': 'host'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='inventory',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'inventory', u'model_name': 'inventory'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='inventory',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'inventory', u'model_name': 'inventory'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='label',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'label', u'model_name': 'label'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='label',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'label', u'model_name': 'label'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notificationtemplate',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'notificationtemplate', u'model_name': 'notificationtemplate'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='notificationtemplate',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'notificationtemplate', u'model_name': 'notificationtemplate'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='organization',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'organization', u'model_name': 'organization'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='organization',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'organization', u'model_name': 'organization'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='schedule',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'schedule', u'model_name': 'schedule'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='schedule',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'schedule', u'model_name': 'schedule'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='team',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'team', u'model_name': 'team'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='team',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'team', u'model_name': 'team'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjob',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjob', u'model_name': 'unifiedjob'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjob',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjob', u'model_name': 'unifiedjob'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjobtemplate',
name='created_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjobtemplate', u'model_name': 'unifiedjobtemplate'}(class)s_created+", to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='unifiedjobtemplate',
name='modified_by',
field=models.ForeignKey(default=None, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="{u'app_label': 'main', u'class': 'unifiedjobtemplate', u'model_name': 'unifiedjobtemplate'}(class)s_modified+", to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.11 on 2018-08-17 16:13
from __future__ import unicode_literals
from decimal import Decimal
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0048_v330_django_created_modified_by_model_name'),
]
operations = [
migrations.AlterField(
model_name='instance',
name='capacity_adjustment',
field=models.DecimalField(decimal_places=2, default=Decimal('1'), max_digits=3, validators=[django.core.validators.MinValueValidator(0)]),
),
]

View File

@@ -35,9 +35,9 @@ def sanitize_event_keys(kwargs, valid_keys):
for key in [
'play', 'role', 'task', 'playbook'
]:
if isinstance(kwargs.get(key), six.string_types):
if len(kwargs[key]) > 1024:
kwargs[key] = Truncator(kwargs[key]).chars(1024)
if isinstance(kwargs.get('event_data', {}).get(key), six.string_types):
if len(kwargs['event_data'][key]) > 1024:
kwargs['event_data'][key] = Truncator(kwargs['event_data'][key]).chars(1024)
def create_host_status_counts(event_data):

View File

@@ -6,6 +6,7 @@ import random
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models, connection
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
@@ -81,6 +82,7 @@ class Instance(HasPolicyEditsMixin, BaseModel):
default=Decimal(1.0),
max_digits=3,
decimal_places=2,
validators=[MinValueValidator(0)]
)
enabled = models.BooleanField(
default=True

View File

@@ -1262,6 +1262,11 @@ class InventorySourceOptions(BaseModel):
'Credentials of type machine, source control, insights and vault are '
'disallowed for custom inventory sources.'
)
elif source == 'scm' and cred and cred.credential_type.kind in ('insights', 'vault'):
return _(
'Credentials of type insights and vault are '
'disallowed for scm inventory sources.'
)
return None
def get_inventory_plugin_name(self):

View File

@@ -238,11 +238,11 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
app_label = 'main'
ordering = ('name',)
host_config_key = models.CharField(
host_config_key = prevent_search(models.CharField(
max_length=1024,
blank=True,
default='',
)
))
ask_diff_mode_on_launch = AskForField(
blank=True,
default=False,

View File

@@ -37,6 +37,7 @@ class SlackBackend(AWXBaseEmailBackend):
if self.color:
ret = connection.api_call("chat.postMessage",
channel=r,
as_user=True,
attachments=[{
"color": self.color,
"text": m.subject

View File

@@ -76,7 +76,8 @@ class TaskManager():
inventory_updates_qs = InventoryUpdate.objects.filter(
status__in=status_list).exclude(source='file').prefetch_related('inventory_source', 'instance_group')
inventory_updates = [i for i in inventory_updates_qs]
project_updates = [p for p in ProjectUpdate.objects.filter(status__in=status_list).prefetch_related('instance_group')]
# Notice the job_type='check': we want to prevent implicit project updates from blocking our jobs.
project_updates = [p for p in ProjectUpdate.objects.filter(status__in=status_list, job_type='check').prefetch_related('instance_group')]
system_jobs = [s for s in SystemJob.objects.filter(status__in=status_list).prefetch_related('instance_group')]
ad_hoc_commands = [a for a in AdHocCommand.objects.filter(status__in=status_list).prefetch_related('instance_group')]
workflow_jobs = [w for w in WorkflowJob.objects.filter(status__in=status_list)]
@@ -678,9 +679,9 @@ class TaskManager():
return finished_wfjs
def schedule(self):
with transaction.atomic():
# Lock
with advisory_lock('task_manager_lock', wait=False) as acquired:
# Lock
with advisory_lock('task_manager_lock', wait=False) as acquired:
with transaction.atomic():
if acquired is False:
logger.debug("Not running scheduler, another task holds lock")
return

View File

@@ -32,7 +32,7 @@ except Exception:
from kombu import Queue, Exchange
from kombu.common import Broadcast
from celery import Task, shared_task
from celery.signals import celeryd_init, worker_shutdown, celeryd_after_setup
from celery.signals import celeryd_init, worker_shutdown
# Django
from django.conf import settings
@@ -108,6 +108,31 @@ def log_celery_failure(self, exc, task_id, args, kwargs, einfo):
@celeryd_init.connect
def celery_startup(conf=None, **kwargs):
#
# When celeryd starts, if the instance cannot be found in the database,
# automatically register it. This is mostly useful for openshift-based
# deployments where:
#
# 2 Instances come online
# Instance B encounters a network blip, Instance A notices, and
# deprovisions it
# Instance B's connectivity is restored, celeryd starts, and it
# re-registers itself
#
# In traditional container-less deployments, instances don't get
# deprovisioned when they miss their heartbeat, so this code is mostly a
# no-op.
#
if kwargs['instance'].hostname != 'celery@{}'.format(settings.CLUSTER_HOST_ID):
error = six.text_type('celery -n {} does not match settings.CLUSTER_HOST_ID={}').format(
instance.hostname, settings.CLUSTER_HOST_ID
)
logger.error(error)
raise RuntimeError(error)
(changed, tower_instance) = Instance.objects.get_or_register()
if changed:
logger.info(six.text_type("Registered tower node '{}'").format(tower_instance.hostname))
startup_logger = logging.getLogger('awx.main.tasks')
startup_logger.info("Syncing Schedules")
for sch in Schedule.objects.all():
@@ -147,9 +172,17 @@ def inform_cluster_of_shutdown(*args, **kwargs):
@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE)
def apply_cluster_membership_policies(self):
started_waiting = time.time()
with advisory_lock('cluster_policy_lock', wait=True):
lock_time = time.time() - started_waiting
if lock_time > 1.0:
to_log = logger.info
else:
to_log = logger.debug
to_log('Waited {} seconds to obtain lock name: cluster_policy_lock'.format(lock_time))
started_compute = time.time()
all_instances = list(Instance.objects.order_by('id'))
all_groups = list(InstanceGroup.objects.all())
all_groups = list(InstanceGroup.objects.prefetch_related('instances'))
iso_hostnames = set([])
for ig in all_groups:
if ig.controller_id is not None:
@@ -159,28 +192,32 @@ def apply_cluster_membership_policies(self):
total_instances = len(considered_instances)
actual_groups = []
actual_instances = []
Group = namedtuple('Group', ['obj', 'instances'])
Group = namedtuple('Group', ['obj', 'instances', 'prior_instances'])
Node = namedtuple('Instance', ['obj', 'groups'])
# Process policy instance list first, these will represent manually managed memberships
instance_hostnames_map = {inst.hostname: inst for inst in all_instances}
for ig in all_groups:
group_actual = Group(obj=ig, instances=[])
group_actual = Group(obj=ig, instances=[], prior_instances=[
instance.pk for instance in ig.instances.all() # obtained in prefetch
])
for hostname in ig.policy_instance_list:
if hostname not in instance_hostnames_map:
logger.info(six.text_type("Unknown instance {} in {} policy list").format(hostname, ig.name))
continue
inst = instance_hostnames_map[hostname]
logger.info(six.text_type("Policy List, adding Instance {} to Group {}").format(inst.hostname, ig.name))
group_actual.instances.append(inst.id)
# NOTE: arguable behavior: policy-list-group is not added to
# instance's group count for consideration in minimum-policy rules
if group_actual.instances:
logger.info(six.text_type("Policy List, adding Instances {} to Group {}").format(group_actual.instances, ig.name))
if ig.controller_id is None:
actual_groups.append(group_actual)
else:
# For isolated groups, _only_ apply the policy_instance_list
# do not add to in-memory list, so minimum rules not applied
logger.info('Committing instances {} to isolated group {}'.format(group_actual.instances, ig.name))
logger.info('Committing instances to isolated group {}'.format(ig.name))
ig.instances.set(group_actual.instances)
# Process Instance minimum policies next, since it represents a concrete lower bound to the
@@ -189,6 +226,7 @@ def apply_cluster_membership_policies(self):
logger.info("Total non-isolated instances:{} available for policy: {}".format(
total_instances, len(actual_instances)))
for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)):
policy_min_added = []
for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)):
if len(g.instances) >= g.obj.policy_instance_minimum:
break
@@ -196,12 +234,15 @@ def apply_cluster_membership_policies(self):
# If the instance is already _in_ the group, it was
# applied earlier via the policy list
continue
logger.info(six.text_type("Policy minimum, adding Instance {} to Group {}").format(i.obj.hostname, g.obj.name))
g.instances.append(i.obj.id)
i.groups.append(g.obj.id)
policy_min_added.append(i.obj.id)
if policy_min_added:
logger.info(six.text_type("Policy minimum, adding Instances {} to Group {}").format(policy_min_added, g.obj.name))
# Finally, process instance policy percentages
for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)):
policy_per_added = []
for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)):
if i.obj.id in g.instances:
# If the instance is already _in_ the group, it was
@@ -209,15 +250,34 @@ def apply_cluster_membership_policies(self):
continue
if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage:
break
logger.info(six.text_type("Policy percentage, adding Instance {} to Group {}").format(i.obj.hostname, g.obj.name))
g.instances.append(i.obj.id)
i.groups.append(g.obj.id)
policy_per_added.append(i.obj.id)
if policy_per_added:
logger.info(six.text_type("Policy percentage, adding Instances {} to Group {}").format(policy_per_added, g.obj.name))
# Determine if any changes need to be made
needs_change = False
for g in actual_groups:
if set(g.instances) != set(g.prior_instances):
needs_change = True
break
if not needs_change:
logger.info('Cluster policy no-op finished in {} seconds'.format(time.time() - started_compute))
return
# On a differential basis, apply instances to non-isolated groups
with transaction.atomic():
for g in actual_groups:
logger.info('Committing instances {} to group {}'.format(g.instances, g.obj.name))
g.obj.instances.set(g.instances)
instances_to_add = set(g.instances) - set(g.prior_instances)
instances_to_remove = set(g.prior_instances) - set(g.instances)
if instances_to_add:
logger.info('Adding instances {} to group {}'.format(list(instances_to_add), g.obj.name))
g.obj.instances.add(*instances_to_add)
if instances_to_remove:
logger.info('Removing instances {} from group {}'.format(list(instances_to_remove), g.obj.name))
g.obj.instances.remove(*instances_to_remove)
logger.info('Cluster policy computation finished in {} seconds'.format(time.time() - started_compute))
@shared_task(exchange='tower_broadcast_all', bind=True)
@@ -233,34 +293,6 @@ def handle_setting_changes(self, setting_keys):
cache.delete_many(cache_keys)
@celeryd_after_setup.connect
def auto_register_ha_instance(sender, instance, **kwargs):
#
# When celeryd starts, if the instance cannot be found in the database,
# automatically register it. This is mostly useful for openshift-based
# deployments where:
#
# 2 Instances come online
# Instance B encounters a network blip, Instance A notices, and
# deprovisions it
# Instance B's connectivity is restored, celeryd starts, and it
# re-registers itself
#
# In traditional container-less deployments, instances don't get
# deprovisioned when they miss their heartbeat, so this code is mostly a
# no-op.
#
if instance.hostname != 'celery@{}'.format(settings.CLUSTER_HOST_ID):
error = six.text_type('celery -n {} does not match settings.CLUSTER_HOST_ID={}').format(
instance.hostname, settings.CLUSTER_HOST_ID
)
logger.error(error)
raise RuntimeError(error)
(changed, tower_instance) = Instance.objects.get_or_register()
if changed:
logger.info(six.text_type("Registered tower node '{}'").format(tower_instance.hostname))
@shared_task(queue=settings.CELERY_DEFAULT_QUEUE)
def send_notifications(notification_list, job_id=None):
if not isinstance(notification_list, list):
@@ -761,12 +793,12 @@ class BaseTask(Task):
os.chmod(path, stat.S_IRUSR)
return path
def add_ansible_venv(self, venv_path, env, add_awx_lib=True):
def add_ansible_venv(self, venv_path, env, add_awx_lib=True, **kwargs):
env['VIRTUAL_ENV'] = venv_path
env['PATH'] = os.path.join(venv_path, "bin") + ":" + env['PATH']
venv_libdir = os.path.join(venv_path, "lib")
if not os.path.exists(venv_libdir):
if not kwargs.get('isolated', False) and not os.path.exists(venv_libdir):
raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path)
)
@@ -1179,7 +1211,7 @@ class RunJob(BaseTask):
plugin_dirs.extend(settings.AWX_ANSIBLE_CALLBACK_PLUGINS)
plugin_path = ':'.join(plugin_dirs)
env = super(RunJob, self).build_env(job, **kwargs)
env = self.add_ansible_venv(job.ansible_virtualenv_path, env, add_awx_lib=kwargs.get('isolated', False))
env = self.add_ansible_venv(job.ansible_virtualenv_path, env, add_awx_lib=kwargs.get('isolated', False), **kwargs)
# Set environment variables needed for inventory and job event
# callbacks to work.
env['JOB_ID'] = str(job.pk)
@@ -2129,8 +2161,7 @@ class RunInventoryUpdate(BaseTask):
elif src == 'scm':
args.append(inventory_update.get_actual_source_path())
elif src == 'custom':
runpath = tempfile.mkdtemp(prefix='awx_inventory_', dir=settings.AWX_PROOT_BASE_PATH)
handle, path = tempfile.mkstemp(dir=runpath)
handle, path = tempfile.mkstemp(dir=kwargs['private_data_dir'])
f = os.fdopen(handle, 'w')
if inventory_update.source_script is None:
raise RuntimeError('Inventory Script does not exist')
@@ -2139,7 +2170,6 @@ class RunInventoryUpdate(BaseTask):
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
args.append(path)
args.append("--custom")
self.cleanup_paths.append(runpath)
args.append('-v%d' % inventory_update.verbosity)
if settings.DEBUG:
args.append('--traceback')

View File

@@ -365,6 +365,116 @@ def test_inventory_source_vars_prohibition(post, inventory, admin_user):
assert 'FOOBAR' in r.data['source_vars'][0]
@pytest.mark.django_db
class TestInventorySourceCredential:
def test_need_cloud_credential(self, inventory, admin_user, post):
"""Test that a cloud-based source requires credential"""
r = post(
url=reverse('api:inventory_source_list'),
data={'inventory': inventory.pk, 'name': 'foo', 'source': 'openstack'},
expect=400,
user=admin_user
)
assert 'Credential is required for a cloud source' in r.data['credential'][0]
def test_ec2_no_credential(self, inventory, admin_user, post):
"""Test that an ec2 inventory source can be added with no credential"""
post(
url=reverse('api:inventory_source_list'),
data={'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2'},
expect=201,
user=admin_user
)
def test_validating_credential_type(self, organization, inventory, admin_user, post):
"""Test that cloud sources must use their respective credential type"""
from awx.main.models.credential import Credential, CredentialType
openstack = CredentialType.defaults['openstack']()
openstack.save()
os_cred = Credential.objects.create(
credential_type=openstack, name='bar', organization=organization)
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'ec2',
'credential': os_cred.pk
},
expect=400,
user=admin_user
)
assert 'Cloud-based inventory sources (such as ec2)' in r.data['credential'][0]
assert 'require credentials for the matching cloud service' in r.data['credential'][0]
def test_vault_credential_not_allowed(self, project, inventory, vault_credential, admin_user, post):
"""Vault credentials cannot be associated via the deprecated field"""
# TODO: when feature is added, add tests to use the related credentials
# endpoint for multi-vault attachment
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
'source_project': project.pk, 'source_path': '',
'credential': vault_credential.pk
},
expect=400,
user=admin_user
)
assert 'Credentials of type insights and vault' in r.data['credential'][0]
assert 'disallowed for scm inventory sources' in r.data['credential'][0]
def test_vault_credential_not_allowed_via_related(
self, project, inventory, vault_credential, admin_user, post):
"""Vault credentials cannot be associated via related endpoint"""
inv_src = InventorySource.objects.create(
inventory=inventory, name='foobar', source='scm',
source_project=project, source_path=''
)
r = post(
url=reverse('api:inventory_source_credentials_list', kwargs={'pk': inv_src.pk}),
data={
'id': vault_credential.pk
},
expect=400,
user=admin_user
)
assert 'Credentials of type insights and vault' in r.data['msg']
assert 'disallowed for scm inventory sources' in r.data['msg']
def test_credentials_relationship_mapping(self, project, inventory, organization, admin_user, post, patch):
"""The credentials relationship is used to manage the cloud credential
this test checks that replacement works"""
from awx.main.models.credential import Credential, CredentialType
openstack = CredentialType.defaults['openstack']()
openstack.save()
os_cred = Credential.objects.create(
credential_type=openstack, name='bar', organization=organization)
r = post(
url=reverse('api:inventory_source_list'),
data={
'inventory': inventory.pk, 'name': 'fobar', 'source': 'scm',
'source_project': project.pk, 'source_path': '',
'credential': os_cred.pk
},
expect=201,
user=admin_user
)
aws = CredentialType.defaults['aws']()
aws.save()
aws_cred = Credential.objects.create(
credential_type=aws, name='bar2', organization=organization)
inv_src = InventorySource.objects.get(pk=r.data['id'])
assert list(inv_src.credentials.values_list('id', flat=True)) == [os_cred.pk]
patch(
url=inv_src.get_absolute_url(),
data={
'credential': aws_cred.pk
},
expect=200,
user=admin_user
)
assert list(inv_src.credentials.values_list('id', flat=True)) == [aws_cred.pk]
@pytest.mark.django_db
class TestControlledBySCM:
'''

View File

@@ -5,7 +5,10 @@ import json
from django.db import connection
from django.test.utils import override_settings
from django.test import Client
from django.core.urlresolvers import resolve
from rest_framework.test import APIRequestFactory
from awx.main.middleware import DeprecatedAuthTokenMiddleware
from awx.main.utils.encryption import decrypt_value, get_encryption_key
from awx.api.versioning import reverse, drf_reverse
from awx.main.models.oauth import (OAuth2Application as Application,
@@ -260,36 +263,6 @@ def test_oauth_list_user_tokens(oauth_application, post, get, admin, alice):
post(url, {'scope': 'read'}, user, expect=201)
response = get(url, admin, expect=200)
assert response.data['count'] == 1
@pytest.mark.django_db
def test_refresh_accesstoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
response = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + base64.b64encode(':'.join([
oauth_application.client_id, oauth_application.client_secret
]))
)
new_token = json.loads(response._container[0])['access_token']
new_refresh_token = json.loads(response._container[0])['refresh_token']
assert token not in AccessToken.objects.all()
assert AccessToken.objects.get(token=new_token) != 0
assert RefreshToken.objects.get(token=new_refresh_token) != 0
refresh_token = RefreshToken.objects.get(token=refresh_token)
assert refresh_token.revoked
@pytest.mark.django_db
@@ -314,3 +287,117 @@ def test_implicit_authorization(oauth_application, admin):
assert 'http://test.com' in response.url and 'access_token' in response.url
# Make sure no refresh token is created for app with implicit grant type.
assert refresh_token_count == RefreshToken.objects.count()
@pytest.mark.django_db
def test_refresh_accesstoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
refresh_url = drf_reverse('api:oauth_authorization_root_view') + 'token/'
response = post(
refresh_url,
data='grant_type=refresh_token&refresh_token=' + refresh_token.token,
content_type='application/x-www-form-urlencoded',
HTTP_AUTHORIZATION='Basic ' + base64.b64encode(':'.join([
oauth_application.client_id, oauth_application.client_secret
]))
)
assert RefreshToken.objects.filter(token=refresh_token).exists()
original_refresh_token = RefreshToken.objects.get(token=refresh_token)
assert token not in AccessToken.objects.all()
assert AccessToken.objects.count() == 1
# the same RefreshToken remains but is marked revoked
assert RefreshToken.objects.count() == 2
new_token = json.loads(response._container[0])['access_token']
new_refresh_token = json.loads(response._container[0])['refresh_token']
assert AccessToken.objects.filter(token=new_token).count() == 1
# checks that RefreshTokens are rotated (new RefreshToken issued)
assert RefreshToken.objects.filter(token=new_refresh_token).count() == 1
assert original_refresh_token.revoked # is not None
@pytest.mark.django_db
def test_revoke_access_then_refreshtoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
token = AccessToken.objects.get(token=response.data['token'])
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
token.revoke()
assert AccessToken.objects.count() == 0
assert RefreshToken.objects.count() == 1
assert not refresh_token.revoked
refresh_token.revoke()
assert AccessToken.objects.count() == 0
assert RefreshToken.objects.count() == 1
@pytest.mark.django_db
def test_revoke_refreshtoken(oauth_application, post, get, delete, admin):
response = post(
reverse('api:o_auth2_application_token_list', kwargs={'pk': oauth_application.pk}),
{'scope': 'read'}, admin, expect=201
)
refresh_token = RefreshToken.objects.get(token=response.data['refresh_token'])
assert AccessToken.objects.count() == 1
assert RefreshToken.objects.count() == 1
refresh_token.revoke()
assert AccessToken.objects.count() == 0
# the same RefreshToken is recycled
new_refresh_token = RefreshToken.objects.all().first()
assert refresh_token == new_refresh_token
assert new_refresh_token.revoked
@pytest.mark.django_db
@pytest.mark.parametrize('fmt', ['json', 'multipart'])
def test_deprecated_authtoken_support(alice, fmt):
kwargs = {
'data': {'username': 'alice', 'password': 'alice'},
'format': fmt
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
DeprecatedAuthTokenMiddleware().process_request(request)
assert request.path == request.path_info == '/api/v2/users/{}/personal_tokens/'.format(alice.pk)
view, view_args, view_kwargs = resolve(request.path)
resp = view(request, *view_args, **view_kwargs)
assert resp.status_code == 201
assert 'token' in resp.data
assert resp.data['refresh_token'] is None
assert resp.data['scope'] == 'write'
@pytest.mark.django_db
def test_deprecated_authtoken_invalid_username(alice):
kwargs = {
'data': {'username': 'nobody', 'password': 'nobody'},
'format': 'json'
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
resp = DeprecatedAuthTokenMiddleware().process_request(request)
assert resp.status_code == 401
@pytest.mark.django_db
def test_deprecated_authtoken_missing_credentials(alice):
kwargs = {
'data': {},
'format': 'json'
}
request = getattr(APIRequestFactory(), 'post')('/api/v2/authtoken/', **kwargs)
resp = DeprecatedAuthTokenMiddleware().process_request(request)
assert resp.status_code == 401

View File

@@ -34,8 +34,17 @@ class TestOAuth2Application:
client_type='confidential', authorization_grant_type='password', organization=organization
)
assert access.can_read(app) is can_access
def test_admin_only_can_read(self, user, organization):
user = user('org-admin', False)
organization.admin_role.members.add(user)
access = OAuth2ApplicationAccess(user)
app = Application.objects.create(
name='test app for {}'.format(user.username), user=user,
client_type='confidential', authorization_grant_type='password', organization=organization
)
assert access.can_read(app) is True
def test_app_activity_stream(self, org_admin, alice, organization):
app = Application.objects.create(
name='test app for {}'.format(org_admin.username), user=org_admin,

View File

@@ -53,9 +53,9 @@ def test_really_long_event_fields(field):
with mock.patch.object(JobEvent, 'objects') as manager:
JobEvent.create_from_data(**{
'job_id': 123,
field: 'X' * 4096
'event_data': {field: 'X' * 4096}
})
manager.create.assert_called_with(**{
'job_id': 123,
field: 'X' * 1021 + '...'
'event_data': {field: 'X' * 1021 + '...'}
})

View File

@@ -2,7 +2,6 @@
# Python
import pytest
import mock
from collections import namedtuple
# AWX
from awx.main.utils.filters import SmartFilter, ExternalLoggerEnabled
@@ -44,8 +43,26 @@ def test_log_configurable_severity(level, expect, dummy_log_record):
assert filter.filter(dummy_log_record) is expect
Field = namedtuple('Field', 'name')
Meta = namedtuple('Meta', 'fields')
class Field(object):
def __init__(self, name, related_model=None, __prevent_search__=None):
self.name = name
self.related_model = related_model
self.__prevent_search__ = __prevent_search__
class Meta(object):
def __init__(self, fields):
self._fields = {
f.name: f for f in fields
}
self.object_name = 'Host'
self.fields_map = {}
self.fields = self._fields.values()
def get_field(self, f):
return self._fields.get(f)
class mockObjects:
@@ -53,15 +70,32 @@ class mockObjects:
return Q(*args, **kwargs)
class mockUser:
def __init__(self):
print("Host user created")
self._meta = Meta(fields=[
Field(name='password', __prevent_search__=True)
])
class mockHost:
def __init__(self):
print("Host mock created")
self.objects = mockObjects()
self._meta = Meta(fields=(Field(name='name'), Field(name='description')))
fields = [
Field(name='name'),
Field(name='description'),
Field(name='created_by', related_model=mockUser())
]
self._meta = Meta(fields=fields)
@mock.patch('awx.main.utils.filters.get_model', return_value=mockHost())
class TestSmartFilterQueryFromString():
@mock.patch(
'awx.api.filters.get_field_from_path',
lambda model, path: (model, path) # disable field filtering, because a__b isn't a real Host field
)
@pytest.mark.parametrize("filter_string,q_expected", [
('facts__facts__blank=""', Q(**{u"facts__facts__blank": u""})),
('"facts__facts__ space "="f"', Q(**{u"facts__facts__ space ": u"f"})),
@@ -88,6 +122,16 @@ class TestSmartFilterQueryFromString():
SmartFilter.query_from_string(filter_string)
assert e.value.message == u"Invalid query " + filter_string
@pytest.mark.parametrize("filter_string", [
'created_by__password__icontains=pbkdf2'
'search=foo or created_by__password__icontains=pbkdf2',
'created_by__password__icontains=pbkdf2 or search=foo',
])
def test_forbidden_filter_string(self, mock_get_host_model, filter_string):
with pytest.raises(Exception) as e:
SmartFilter.query_from_string(filter_string)
"Filtering on password is not allowed." in str(e)
@pytest.mark.parametrize("filter_string,q_expected", [
(u'(a=abc\u1F5E3def)', Q(**{u"a": u"abc\u1F5E3def"})),
(u'(ansible_facts__a=abc\u1F5E3def)', Q(**{u"ansible_facts__contains": {u"a": u"abc\u1F5E3def"}})),

View File

@@ -1,68 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2017 Ansible Tower by Red Hat
# All Rights Reserved.
# python
import pytest
import mock
# AWX
from awx.main.utils.ha import (
AWXCeleryRouter,
)
class TestAddRemoveCeleryWorkerQueues():
@pytest.fixture
def instance_generator(self, mocker):
def fn(hostname='east-1'):
groups=['east', 'west', 'north', 'south']
instance = mocker.MagicMock()
instance.hostname = hostname
instance.rampart_groups = mocker.MagicMock()
instance.rampart_groups.values_list = mocker.MagicMock(return_value=groups)
return instance
return fn
@pytest.fixture
def worker_queues_generator(self, mocker):
def fn(queues=['east', 'west']):
return [dict(name=n, alias='') for n in queues]
return fn
@pytest.fixture
def mock_app(self, mocker):
app = mocker.MagicMock()
app.control = mocker.MagicMock()
app.control.cancel_consumer = mocker.MagicMock()
return app
class TestUpdateCeleryWorkerRouter():
@pytest.mark.parametrize("is_controller,expected_routes", [
(False, {
'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'}
}),
(True, {
'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'},
'awx.main.tasks.awx_isolated_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'},
}),
])
def test_update_celery_worker_routes(self, mocker, is_controller, expected_routes):
def get_or_register():
instance = mock.MagicMock()
instance.hostname = 'east-1'
instance.is_controller = mock.MagicMock(return_value=is_controller)
return (False, instance)
with mock.patch('awx.main.models.Instance.objects.get_or_register', get_or_register):
router = AWXCeleryRouter()
for k,v in expected_routes.iteritems():
assert router.route_for_task(k) == v

View File

@@ -147,6 +147,10 @@ class SmartFilter(object):
q = reduce(lambda x, y: x | y, [models.Q(**{u'%s__icontains' % _k:_v}) for _k, _v in kwargs.items()])
self.result = Host.objects.filter(q)
else:
# detect loops and restrict access to sensitive fields
# this import is intentional here to avoid a circular import
from awx.api.filters import FieldLookupBackend
FieldLookupBackend().get_field_from_lookup(Host, k)
kwargs[k] = v
self.result = Host.objects.filter(**kwargs)

View File

@@ -3,21 +3,15 @@
# Copyright (c) 2017 Ansible Tower by Red Hat
# All Rights Reserved.
from awx.main.models import Instance
from django.conf import settings
class AWXCeleryRouter(object):
def route_for_task(self, task, args=None, kwargs=None):
(changed, instance) = Instance.objects.get_or_register()
tasks = [
'awx.main.tasks.cluster_node_heartbeat',
'awx.main.tasks.purge_old_stdout_files',
]
isolated_tasks = [
'awx.main.tasks.awx_isolated_heartbeat',
]
if task in tasks:
return {'queue': instance.hostname.encode("utf8"), 'routing_key': instance.hostname.encode("utf8")}
if instance.is_controller() and task in isolated_tasks:
return {'queue': instance.hostname.encode("utf8"), 'routing_key': instance.hostname.encode("utf8")}
return {'queue': settings.CLUSTER_HOST_ID, 'routing_key': settings.CLUSTER_HOST_ID}