Merge branch 'release_3.1.3' into devel

* release_3.1.3: (52 commits)
  ack fact scan messages
  making ldap user/group search fields into codemirror instances
  removing UI parsing for LDAP User and Group Search fields
  Allow exception view to accept all valid HTTP methods.
  Restore ability of parsing extra_vars string for provisioning callback.
  Fix up backup/restore role broken in f7a8e45809758322d9ee41c5305850dd70ed5faf
  Stop / start ansible-tower-service during restores
  value_to_python should encode lookup fields as ascii
  fix brace interpolation on standard out pane
  Adjust some hardcoded usages of 'awx' to use 'aw_user' and 'aw_group'.
  Pull Spanish updates from Zanata
  Temporarily grant awx user createdb role
  Stop giving ownership of backups to postgres
  don't display chunked lines'
  Add dropdown li truncation with ellipsis
  CTiT -> adhoc modules should allow the user to add new modules
  Remove task that was replacing the supervisor systemd tmp file
  Fix failing supervisorctl commands on RH-based distros
  Give ownership of the supervisor socket to awx
  Setting for external log emissions cert verification
  ...
This commit is contained in:
Matthew Jones
2017-04-28 13:57:04 -04:00
50 changed files with 3787 additions and 2938 deletions

View File

@@ -410,13 +410,13 @@ uwsgi: collectstatic
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \ . $(VENV_BASE)/tower/bin/activate; \
fi; \ fi; \
uwsgi -b 32768 --socket :8050 --module=awx.wsgi:application --home=/venv/tower --chdir=/tower_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --master-fifo=/awxfifo --lazy-apps uwsgi -b 32768 --socket 127.0.0.1:8050 --module=awx.wsgi:application --home=/venv/tower --chdir=/tower_devel/ --vacuum --processes=5 --harakiri=120 --master --no-orphans --py-autoreload 1 --max-requests=1000 --stats /tmp/stats.socket --master-fifo=/awxfifo --lazy-apps
daphne: daphne:
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \ . $(VENV_BASE)/tower/bin/activate; \
fi; \ fi; \
daphne -b 0.0.0.0 -p 8051 awx.asgi:channel_layer daphne -b 127.0.0.1 -p 8051 awx.asgi:channel_layer
runworker: runworker:
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
@@ -702,13 +702,19 @@ rpm-build:
mkdir -p $@ mkdir -p $@
rpm-build/$(SDIST_TAR_FILE): rpm-build dist/$(SDIST_TAR_FILE) tar-build/$(SETUP_TAR_FILE) rpm-build/$(SDIST_TAR_FILE): rpm-build dist/$(SDIST_TAR_FILE) tar-build/$(SETUP_TAR_FILE)
cp packaging/rpm/$(NAME).spec rpm-build/ ansible localhost \
-m template \
-a "src=packaging/rpm/$(NAME).spec.j2 dest=rpm-build/$(NAME).spec" \
-e tower_version=$(VERSION) \
-e tower_release=$(RELEASE)
cp packaging/rpm/tower.te rpm-build/ cp packaging/rpm/tower.te rpm-build/
cp packaging/rpm/tower.fc rpm-build/ cp packaging/rpm/tower.fc rpm-build/
cp packaging/rpm/$(NAME).sysconfig rpm-build/ cp packaging/rpm/$(NAME).sysconfig rpm-build/
cp packaging/remove_tower_source.py rpm-build/ cp packaging/remove_tower_source.py rpm-build/
cp packaging/bytecompile.sh rpm-build/ cp packaging/bytecompile.sh rpm-build/
cp tar-build/$(SETUP_TAR_FILE) rpm-build/ cp tar-build/$(SETUP_TAR_FILE) rpm-build/
if [ "$(OFFICIAL)" != "yes" ] ; then \ if [ "$(OFFICIAL)" != "yes" ] ; then \
(cd dist/ && tar zxf $(SDIST_TAR_FILE)) ; \ (cd dist/ && tar zxf $(SDIST_TAR_FILE)) ; \
(cd dist/ && mv $(NAME)-$(VERSION)-$(BUILD) $(NAME)-$(VERSION)) ; \ (cd dist/ && mv $(NAME)-$(VERSION)-$(BUILD) $(NAME)-$(VERSION)) ; \
@@ -764,8 +770,7 @@ requirements/requirements_ansible_local.txt:
--dest=requirements/vendor 2>/dev/null | sed -n 's/^\s*Saved\s*//p' > $@ --dest=requirements/vendor 2>/dev/null | sed -n 's/^\s*Saved\s*//p' > $@
rpm-build/$(RPM_NVR).src.rpm: /etc/mock/$(MOCK_CFG).cfg rpm-build/$(RPM_NVR).src.rpm: /etc/mock/$(MOCK_CFG).cfg
$(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --buildsrpm --spec rpm-build/$(NAME).spec --sources rpm-build \ $(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --buildsrpm --spec rpm-build/$(NAME).spec --sources rpm-build
--define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES)
mock-srpm: rpmtar rpm-build/$(RPM_NVR).src.rpm mock-srpm: rpmtar rpm-build/$(RPM_NVR).src.rpm
@echo "#############################################" @echo "#############################################"
@@ -776,8 +781,7 @@ mock-srpm: rpmtar rpm-build/$(RPM_NVR).src.rpm
brew-srpm: brewrpmtar mock-srpm brew-srpm: brewrpmtar mock-srpm
rpm-build/$(RPM_NVR).$(RPM_ARCH).rpm: rpm-build/$(RPM_NVR).src.rpm rpm-build/$(RPM_NVR).$(RPM_ARCH).rpm: rpm-build/$(RPM_NVR).src.rpm
$(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --rebuild rpm-build/$(RPM_NVR).src.rpm \ $(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --rebuild rpm-build/$(RPM_NVR).src.rpm
--define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES)
mock-rpm: rpmtar rpm-build/$(RPM_NVR).$(RPM_ARCH).rpm mock-rpm: rpmtar rpm-build/$(RPM_NVR).$(RPM_ARCH).rpm
@echo "#############################################" @echo "#############################################"

View File

@@ -234,7 +234,8 @@ class ApiV1PingView(APIView):
response['instances'] = [] response['instances'] = []
for instance in Instance.objects.all(): for instance in Instance.objects.all():
response['instances'].append(dict(node=instance.hostname, heartbeat=instance.modified, capacity=instance.capacity)) response['instances'].append(dict(node=instance.hostname, heartbeat=instance.modified,
capacity=instance.capacity, version=instance.version))
response['instances'].sort() response['instances'].sort()
return Response(response) return Response(response)

View File

@@ -31,6 +31,16 @@ class CharField(CharField):
return super(CharField, self).to_representation(value) return super(CharField, self).to_representation(value)
class IntegerField(IntegerField):
def get_value(self, dictionary):
ret = super(IntegerField, self).get_value(dictionary)
# Handle UI corner case
if ret == '' and self.allow_null and not getattr(self, 'allow_blank', False):
return None
return ret
class StringListField(ListField): class StringListField(ListField):
child = CharField() child = CharField()

View File

@@ -86,7 +86,19 @@ def executor(tmpdir_factory, request):
- name: Hello Message - name: Hello Message
debug: debug:
msg: "Hello World!" msg: "Hello World!"
'''} # noqa '''}, # noqa
{'results_included.yml': '''
- name: Run module which generates results list
connection: local
hosts: all
gather_facts: no
vars:
results: ['foo', 'bar']
tasks:
- name: Generate results list
debug:
var: results
'''}, # noqa
]) ])
def test_callback_plugin_receives_events(executor, cache, event, playbook): def test_callback_plugin_receives_events(executor, cache, event, playbook):
executor.run() executor.run()

View File

@@ -63,7 +63,6 @@ class BaseCallbackModule(CallbackBase):
@contextlib.contextmanager @contextlib.contextmanager
def capture_event_data(self, event, **event_data): def capture_event_data(self, event, **event_data):
event_data.setdefault('uuid', str(uuid.uuid4())) event_data.setdefault('uuid', str(uuid.uuid4()))
if event not in self.EVENTS_WITHOUT_TASK: if event not in self.EVENTS_WITHOUT_TASK:
@@ -75,7 +74,7 @@ class BaseCallbackModule(CallbackBase):
if event_data['res'].get('_ansible_no_log', False): if event_data['res'].get('_ansible_no_log', False):
event_data['res'] = {'censored': CENSORED} event_data['res'] = {'censored': CENSORED}
for i, item in enumerate(event_data['res'].get('results', [])): for i, item in enumerate(event_data['res'].get('results', [])):
if event_data['res']['results'][i].get('_ansible_no_log', False): if isinstance(item, dict) and item.get('_ansible_no_log', False):
event_data['res']['results'][i] = {'censored': CENSORED} event_data['res']['results'][i] = {'censored': CENSORED}
with event_context.display_lock: with event_context.display_lock:

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -61,12 +61,15 @@ class FactBrokerWorker(ConsumerMixin):
host_obj = Host.objects.get(name=hostname, inventory__id=inventory_id) host_obj = Host.objects.get(name=hostname, inventory__id=inventory_id)
except Fact.DoesNotExist: except Fact.DoesNotExist:
logger.warn('Failed to intake fact. Host does not exist <hostname, inventory_id> <%s, %s>' % (hostname, inventory_id)) logger.warn('Failed to intake fact. Host does not exist <hostname, inventory_id> <%s, %s>' % (hostname, inventory_id))
message.ack()
return return
except Fact.MultipleObjectsReturned: except Fact.MultipleObjectsReturned:
logger.warn('Database inconsistent. Multiple Hosts found for <hostname, inventory_id> <%s, %s>.' % (hostname, inventory_id)) logger.warn('Database inconsistent. Multiple Hosts found for <hostname, inventory_id> <%s, %s>.' % (hostname, inventory_id))
message.ack()
return None return None
except Exception as e: except Exception as e:
logger.error("Exception communicating with Fact Cache Database: %s" % str(e)) logger.error("Exception communicating with Fact Cache Database: %s" % str(e))
message.ack()
return None return None
(module_name, facts) = self.process_facts(facts_data) (module_name, facts) = self.process_facts(facts_data)
@@ -84,6 +87,7 @@ class FactBrokerWorker(ConsumerMixin):
logger.info('Created new fact <fact_id, module> <%s, %s>' % (fact_obj.id, module_name)) logger.info('Created new fact <fact_id, module> <%s, %s>' % (fact_obj.id, module_name))
analytics_logger.info('Received message with fact data', extra=dict( analytics_logger.info('Received message with fact data', extra=dict(
module_name=module_name, facts_data=facts)) module_name=module_name, facts_data=facts))
message.ack()
return fact_obj return fact_obj

View File

@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0036_v311_insights'),
]
operations = [
migrations.AddField(
model_name='instance',
name='version',
field=models.CharField(max_length=24, blank=True),
),
]

View File

@@ -26,6 +26,7 @@ class Instance(models.Model):
hostname = models.CharField(max_length=250, unique=True) hostname = models.CharField(max_length=250, unique=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True) modified = models.DateTimeField(auto_now=True)
version = models.CharField(max_length=24, blank=True)
capacity = models.PositiveIntegerField( capacity = models.PositiveIntegerField(
default=100, default=100,
editable=False, editable=False,

View File

@@ -596,7 +596,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin):
playbook=self.playbook, playbook=self.playbook,
credential=self.credential.name if self.credential else None, credential=self.credential.name if self.credential else None,
limit=self.limit, limit=self.limit,
extra_vars=self.extra_vars, extra_vars=self.display_extra_vars(),
hosts=all_hosts)) hosts=all_hosts))
return data return data

View File

@@ -1,5 +1,6 @@
# Python # Python
import json import json
from copy import copy
# Django # Django
from django.db import models from django.db import models
@@ -28,9 +29,7 @@ class ResourceMixin(models.Model):
''' '''
Use instead of `MyModel.objects` when you want to only consider Use instead of `MyModel.objects` when you want to only consider
resources that a user has specific permissions for. For example: resources that a user has specific permissions for. For example:
MyModel.accessible_objects(user, 'read_role').filter(name__istartswith='bar'); MyModel.accessible_objects(user, 'read_role').filter(name__istartswith='bar');
NOTE: This should only be used for list type things. If you have a NOTE: This should only be used for list type things. If you have a
specific resource you want to check permissions on, it is more specific resource you want to check permissions on, it is more
performant to resolve the resource in question then call performant to resolve the resource in question then call
@@ -159,12 +158,13 @@ class SurveyJobTemplateMixin(models.Model):
errors.append("Value %s for '%s' expected to be a string." % (data[survey_element['variable']], errors.append("Value %s for '%s' expected to be a string." % (data[survey_element['variable']],
survey_element['variable'])) survey_element['variable']))
return errors return errors
if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']): if not data[survey_element['variable']] == '$encrypted$' and not survey_element['type'] == 'password':
errors.append("'%s' value %s is too small (length is %s must be at least %s)." % if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']):
(survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min'])) errors.append("'%s' value %s is too small (length is %s must be at least %s)." %
if 'max' in survey_element and survey_element['max'] not in ["", None] and len(data[survey_element['variable']]) > int(survey_element['max']): (survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min']))
errors.append("'%s' value %s is too large (must be no more than %s)." % if 'max' in survey_element and survey_element['max'] not in ["", None] and len(data[survey_element['variable']]) > int(survey_element['max']):
(survey_element['variable'], data[survey_element['variable']], survey_element['max'])) errors.append("'%s' value %s is too large (must be no more than %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['max']))
elif survey_element['type'] == 'integer': elif survey_element['type'] == 'integer':
if survey_element['variable'] in data: if survey_element['variable'] in data:
if type(data[survey_element['variable']]) != int: if type(data[survey_element['variable']]) != int:
@@ -196,16 +196,22 @@ class SurveyJobTemplateMixin(models.Model):
if type(data[survey_element['variable']]) != list: if type(data[survey_element['variable']]) != list:
errors.append("'%s' value is expected to be a list." % survey_element['variable']) errors.append("'%s' value is expected to be a list." % survey_element['variable'])
else: else:
choice_list = copy(survey_element['choices'])
if isinstance(choice_list, basestring):
choice_list = choice_list.split('\n')
for val in data[survey_element['variable']]: for val in data[survey_element['variable']]:
if val not in survey_element['choices']: if val not in choice_list:
errors.append("Value %s for '%s' expected to be one of %s." % (val, survey_element['variable'], errors.append("Value %s for '%s' expected to be one of %s." % (val, survey_element['variable'],
survey_element['choices'])) choice_list))
elif survey_element['type'] == 'multiplechoice': elif survey_element['type'] == 'multiplechoice':
choice_list = copy(survey_element['choices'])
if isinstance(choice_list, basestring):
choice_list = choice_list.split('\n')
if survey_element['variable'] in data: if survey_element['variable'] in data:
if data[survey_element['variable']] not in survey_element['choices']: if data[survey_element['variable']] not in choice_list:
errors.append("Value %s for '%s' expected to be one of %s." % (data[survey_element['variable']], errors.append("Value %s for '%s' expected to be one of %s." % (data[survey_element['variable']],
survey_element['variable'], survey_element['variable'],
survey_element['choices'])) choice_list))
return errors return errors
def survey_variable_validation(self, data): def survey_variable_validation(self, data):

View File

@@ -204,6 +204,12 @@ class ProjectOptions(models.Model):
break break
return sorted(results, key=lambda x: smart_str(x).lower()) return sorted(results, key=lambda x: smart_str(x).lower())
def get_lock_file(self):
proj_path = self.get_project_path()
if not proj_path:
return None
return proj_path + '.lock'
class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin):
''' '''

View File

@@ -21,7 +21,9 @@ import traceback
import urlparse import urlparse
import uuid import uuid
from distutils.version import LooseVersion as Version from distutils.version import LooseVersion as Version
from datetime import timedelta
import yaml import yaml
import fcntl
try: try:
import psutil import psutil
except: except:
@@ -46,6 +48,7 @@ from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
# AWX # AWX
from awx import __version__ as tower_application_version
from awx.main.constants import CLOUD_PROVIDERS from awx.main.constants import CLOUD_PROVIDERS
from awx.main.models import * # noqa from awx.main.models import * # noqa
from awx.main.models.unified_jobs import ACTIVE_STATES from awx.main.models.unified_jobs import ACTIVE_STATES
@@ -54,7 +57,7 @@ from awx.main.task_engine import TaskEnhancer
from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url,
check_proot_installed, build_proot_temp_dir, wrap_args_with_proot, check_proot_installed, build_proot_temp_dir, wrap_args_with_proot,
get_system_task_capacity, OutputEventFilter, parse_yaml_or_json) get_system_task_capacity, OutputEventFilter, parse_yaml_or_json)
from awx.main.utils.reload import restart_local_services from awx.main.utils.reload import restart_local_services, stop_local_services
from awx.main.utils.handlers import configure_external_logger from awx.main.utils.handlers import configure_external_logger
from awx.main.consumers import emit_channel_notification from awx.main.consumers import emit_channel_notification
@@ -175,13 +178,27 @@ def purge_old_stdout_files(self):
@task(bind=True) @task(bind=True)
def cluster_node_heartbeat(self): def cluster_node_heartbeat(self):
logger.debug("Cluster node heartbeat task.") logger.debug("Cluster node heartbeat task.")
nowtime = now()
inst = Instance.objects.filter(hostname=settings.CLUSTER_HOST_ID) inst = Instance.objects.filter(hostname=settings.CLUSTER_HOST_ID)
if inst.exists(): if inst.exists():
inst = inst[0] inst = inst[0]
inst.capacity = get_system_task_capacity() inst.capacity = get_system_task_capacity()
inst.version = tower_application_version
inst.save() inst.save()
return else:
raise RuntimeError("Cluster Host Not Found: {}".format(settings.CLUSTER_HOST_ID)) raise RuntimeError("Cluster Host Not Found: {}".format(settings.CLUSTER_HOST_ID))
recent_inst = Instance.objects.filter(modified__gt=nowtime - timedelta(seconds=70)).exclude(hostname=settings.CLUSTER_HOST_ID)
# IFF any node has a greater version than we do, then we'll shutdown services
for other_inst in recent_inst:
if other_inst.version == "":
continue
if Version(other_inst.version) > Version(tower_application_version):
logger.error("Host {} reports Tower version {}, but this node {} is at {}, shutting down".format(other_inst.hostname,
other_inst.version,
inst.hostname,
inst.version))
stop_local_services(['uwsgi', 'celery', 'beat', 'callback', 'fact'])
@task(bind=True, queue='default') @task(bind=True, queue='default')
@@ -1365,7 +1382,45 @@ class RunProjectUpdate(BaseTask):
logger.error('Encountered error updating project dependent inventory: {}'.format(e)) logger.error('Encountered error updating project dependent inventory: {}'.format(e))
continue continue
def release_lock(self, instance):
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_UN)
except IOError as e:
logger.error("I/O error({0}) while trying to open lock file [{1}]: {2}".format(e.errno, instance.get_lock_file(), e.strerror))
os.close(self.lock_fd)
raise
os.close(self.lock_fd)
self.lock_fd = None
'''
Note: We don't support blocking=False
'''
def acquire_lock(self, instance, blocking=True):
lock_path = instance.get_lock_file()
if lock_path is None:
raise RuntimeError(u'Invalid lock file path')
try:
self.lock_fd = os.open(lock_path, os.O_RDONLY | os.O_CREAT)
except OSError as e:
logger.error("I/O error({0}) while trying to open lock file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
raise
try:
fcntl.flock(self.lock_fd, fcntl.LOCK_EX)
except IOError as e:
os.close(self.lock_fd)
logger.error("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(e.errno, lock_path, e.strerror))
raise
def pre_run_hook(self, instance, **kwargs):
if instance.launch_type == 'sync':
self.acquire_lock(instance)
def post_run_hook(self, instance, status, **kwargs): def post_run_hook(self, instance, status, **kwargs):
if instance.launch_type == 'sync':
self.release_lock(instance)
p = instance.project p = instance.project
dependent_inventory_sources = p.scm_inventory_sources.all() dependent_inventory_sources = p.scm_inventory_sources.all()
if instance.job_type == 'check' and status not in ('failed', 'canceled',): if instance.job_type == 'check' and status not in ('failed', 'canceled',):

View File

@@ -135,13 +135,15 @@ def create_survey_spec(variables=None, default_type='integer', required=True):
argument specifying variable name(s) argument specifying variable name(s)
''' '''
if isinstance(variables, list): if isinstance(variables, list):
name = "%s survey" % variables[0]
description = "A survey that starts with %s." % variables[0]
vars_list = variables vars_list = variables
else: else:
name = "%s survey" % variables
description = "A survey about %s." % variables
vars_list = [variables] vars_list = [variables]
if isinstance(variables[0], basestring):
slogan = variables[0]
else:
slogan = variables[0].get('question_name', 'something')
name = "%s survey" % slogan
description = "A survey that asks about %s." % slogan
spec = [] spec = []
index = 0 index = 0

View File

@@ -2,6 +2,8 @@ from awx.main.models import Job, Instance
from django.test.utils import override_settings from django.test.utils import override_settings
import pytest import pytest
import json
@pytest.mark.django_db @pytest.mark.django_db
def test_orphan_unified_job_creation(instance, inventory): def test_orphan_unified_job_creation(instance, inventory):
@@ -22,3 +24,15 @@ def test_job_capacity_and_with_inactive_node():
assert Instance.objects.total_capacity() == 100 assert Instance.objects.total_capacity() == 100
with override_settings(AWX_ACTIVE_NODE_TIME=0): with override_settings(AWX_ACTIVE_NODE_TIME=0):
assert Instance.objects.total_capacity() < 100 assert Instance.objects.total_capacity() < 100
@pytest.mark.django_db
def test_job_notification_data(inventory):
encrypted_str = "$encrypted$"
job = Job.objects.create(
job_template=None, inventory=inventory, name='hi world',
extra_vars=json.dumps({"SSN": "123-45-6789"}),
survey_passwords={"SSN": encrypted_str}
)
notification_data = job.notification_data(block=0)
assert json.loads(notification_data['extra_vars'])['SSN'] == encrypted_str

View File

@@ -91,6 +91,40 @@ def test_update_kwargs_survey_invalid_default(survey_spec_factory):
assert json.loads(defaulted_extra_vars['extra_vars'])['var2'] == 2 assert json.loads(defaulted_extra_vars['extra_vars'])['var2'] == 2
@pytest.mark.parametrize("question_type,default,expect_use,expect_value", [
("multiplechoice", "", False, 'N/A'), # historical bug
("multiplechoice", "zeb", False, 'N/A'), # zeb not in choices
("multiplechoice", "coffee", True, 'coffee'),
("multiselect", None, False, 'N/A'), # NOTE: Behavior is arguable, value of [] may be prefered
("multiselect", "", False, 'N/A'),
("multiselect", ["zeb"], False, 'N/A'),
("multiselect", ["milk"], True, ["milk"]),
("multiselect", ["orange\nmilk"], False, 'N/A'), # historical bug
])
def test_optional_survey_question_defaults(
survey_spec_factory, question_type, default, expect_use, expect_value):
spec = survey_spec_factory([
{
"required": False,
"default": default,
"choices": "orange\nmilk\nchocolate\ncoffee",
"variable": "c",
"type": question_type
},
])
jt = JobTemplate(name="test-jt", survey_spec=spec, survey_enabled=True)
defaulted_extra_vars = jt._update_unified_job_kwargs()
element = spec['spec'][0]
if expect_use:
assert jt._survey_element_validation(element, {element['variable']: element['default']}) == []
else:
assert jt._survey_element_validation(element, {element['variable']: element['default']})
if expect_use:
assert json.loads(defaulted_extra_vars['extra_vars'])['c'] == expect_value
else:
assert 'c' not in defaulted_extra_vars['extra_vars']
class TestWorkflowSurveys: class TestWorkflowSurveys:
def test_update_kwargs_survey_defaults(self, survey_spec_factory): def test_update_kwargs_survey_defaults(self, survey_spec_factory):
"Assure that the survey default over-rides a JT variable" "Assure that the survey default over-rides a JT variable"

View File

@@ -5,9 +5,11 @@ import ConfigParser
import json import json
import tempfile import tempfile
import os
import fcntl
import pytest import pytest
import yaml
import mock import mock
import yaml
from awx.main.models import ( from awx.main.models import (
Credential, Credential,
@@ -1067,3 +1069,62 @@ class TestInventoryUpdateCredentials(TestJobExecution):
self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect) self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect)
self.task.run(self.pk) self.task.run(self.pk)
def test_os_open_oserror():
with pytest.raises(OSError):
os.open('this_file_does_not_exist', os.O_RDONLY)
def test_fcntl_ioerror():
with pytest.raises(IOError):
fcntl.flock(99999, fcntl.LOCK_EX)
@mock.patch('os.open')
@mock.patch('logging.getLogger')
def test_aquire_lock_open_fail_logged(logging_getLogger, os_open):
err = OSError()
err.errno = 3
err.strerror = 'dummy message'
instance = mock.Mock()
instance.get_lock_file.return_value = 'this_file_does_not_exist'
os_open.side_effect = err
logger = mock.Mock()
logging_getLogger.return_value = logger
ProjectUpdate = tasks.RunProjectUpdate()
with pytest.raises(OSError, errno=3, strerror='dummy message'):
ProjectUpdate.acquire_lock(instance)
assert logger.err.called_with("I/O error({0}) while trying to open lock file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message'))
@mock.patch('os.open')
@mock.patch('os.close')
@mock.patch('logging.getLogger')
@mock.patch('fcntl.flock')
def test_aquire_lock_acquisition_fail_logged(fcntl_flock, logging_getLogger, os_close, os_open):
err = IOError()
err.errno = 3
err.strerror = 'dummy message'
instance = mock.Mock()
instance.get_lock_file.return_value = 'this_file_does_not_exist'
os_open.return_value = 3
logger = mock.Mock()
logging_getLogger.return_value = logger
fcntl_flock.side_effect = err
ProjectUpdate = tasks.RunProjectUpdate()
with pytest.raises(IOError, errno=3, strerror='dummy message'):
ProjectUpdate.acquire_lock(instance)
os_close.assert_called_with(3)
assert logger.err.called_with("I/O error({0}) while trying to aquire lock on file [{1}]: {2}".format(3, 'this_file_does_not_exist', 'dummy message'))

View File

@@ -0,0 +1,37 @@
import pytest
import mock
# Django REST Framework
from rest_framework import exceptions
# AWX
from awx.main.views import ApiErrorView
HTTP_METHOD_NAMES = [
'get',
'post',
'put',
'patch',
'delete',
'head',
'options',
'trace',
]
@pytest.fixture
def api_view_obj_fixture():
return ApiErrorView()
@pytest.mark.parametrize('method_name', HTTP_METHOD_NAMES)
def test_exception_view_allow_http_methods(method_name):
assert hasattr(ApiErrorView, method_name)
@pytest.mark.parametrize('method_name', HTTP_METHOD_NAMES)
def test_exception_view_raises_exception(api_view_obj_fixture, method_name):
request_mock = mock.MagicMock()
with pytest.raises(exceptions.APIException):
getattr(api_view_obj_fixture, method_name)(request_mock)

View File

@@ -2,6 +2,7 @@
# Copyright (c) 2017 Ansible, Inc. # Copyright (c) 2017 Ansible, Inc.
# All Rights Reserved. # All Rights Reserved.
import pytest
from awx.conf.models import Setting from awx.conf.models import Setting
from awx.main.utils import common from awx.main.utils import common
@@ -52,3 +53,13 @@ def test_encrypt_field_with_ask():
def test_encrypt_field_with_empty_value(): def test_encrypt_field_with_empty_value():
encrypted = common.encrypt_field(Setting(value=None), 'value') encrypted = common.encrypt_field(Setting(value=None), 'value')
assert encrypted is None assert encrypted is None
@pytest.mark.parametrize('input_, output', [
({"foo": "bar"}, {"foo": "bar"}),
('{"foo": "bar"}', {"foo": "bar"}),
('---\nfoo: bar', {"foo": "bar"}),
(4399, {}),
])
def test_parse_yaml_or_json(input_, output):
assert common.parse_yaml_or_json(input_) == output

View File

@@ -3,10 +3,15 @@ from awx.main.utils import reload
def test_produce_supervisor_command(mocker): def test_produce_supervisor_command(mocker):
with mocker.patch.object(reload.subprocess, 'Popen'): communicate_mock = mocker.MagicMock(return_value=('Everything is fine', ''))
reload._supervisor_service_restart(['beat', 'callback', 'fact']) mock_process = mocker.MagicMock()
mock_process.communicate = communicate_mock
Popen_mock = mocker.MagicMock(return_value=mock_process)
with mocker.patch.object(reload.subprocess, 'Popen', Popen_mock):
reload._supervisor_service_command(['beat', 'callback', 'fact'], "restart")
reload.subprocess.Popen.assert_called_once_with( reload.subprocess.Popen.assert_called_once_with(
['supervisorctl', 'restart', 'tower-processes:receiver', 'tower-processes:factcacher']) ['supervisorctl', 'restart', 'tower-processes:receiver', 'tower-processes:factcacher'],
stderr=-1, stdin=-1, stdout=-1)
def test_routing_of_service_restarts_works(mocker): def test_routing_of_service_restarts_works(mocker):
@@ -14,13 +19,13 @@ def test_routing_of_service_restarts_works(mocker):
This tests that the parent restart method will call the appropriate This tests that the parent restart method will call the appropriate
service restart methods, depending on which services are given in args service restart methods, depending on which services are given in args
''' '''
with mocker.patch.object(reload, '_uwsgi_reload'),\ with mocker.patch.object(reload, '_uwsgi_fifo_command'),\
mocker.patch.object(reload, '_reset_celery_thread_pool'),\ mocker.patch.object(reload, '_reset_celery_thread_pool'),\
mocker.patch.object(reload, '_supervisor_service_restart'): mocker.patch.object(reload, '_supervisor_service_command'):
reload.restart_local_services(['uwsgi', 'celery', 'flower', 'daphne']) reload.restart_local_services(['uwsgi', 'celery', 'flower', 'daphne'])
reload._uwsgi_reload.assert_called_once_with() reload._uwsgi_fifo_command.assert_called_once_with(uwsgi_command="c")
reload._reset_celery_thread_pool.assert_called_once_with() reload._reset_celery_thread_pool.assert_called_once_with()
reload._supervisor_service_restart.assert_called_once_with(['flower', 'daphne']) reload._supervisor_service_command.assert_called_once_with(['flower', 'daphne'], command="restart")
@@ -28,11 +33,11 @@ def test_routing_of_service_restarts_diables(mocker):
''' '''
Test that methods are not called if not in the args Test that methods are not called if not in the args
''' '''
with mocker.patch.object(reload, '_uwsgi_reload'),\ with mocker.patch.object(reload, '_uwsgi_fifo_command'),\
mocker.patch.object(reload, '_reset_celery_thread_pool'),\ mocker.patch.object(reload, '_reset_celery_thread_pool'),\
mocker.patch.object(reload, '_supervisor_service_restart'): mocker.patch.object(reload, '_supervisor_service_command'):
reload.restart_local_services(['flower']) reload.restart_local_services(['flower'])
reload._uwsgi_reload.assert_not_called() reload._uwsgi_fifo_command.assert_not_called()
reload._reset_celery_thread_pool.assert_not_called() reload._reset_celery_thread_pool.assert_not_called()
reload._supervisor_service_restart.assert_called_once_with(['flower']) reload._supervisor_service_command.assert_called_once_with(['flower'], command="restart")

View File

@@ -854,8 +854,8 @@ class OutputEventFilter(object):
def callback_filter_out_ansible_extra_vars(extra_vars): def callback_filter_out_ansible_extra_vars(extra_vars):
extra_vars_redacted = {} extra_vars_redacted = {}
extra_vars = parse_yaml_or_json(extra_vars)
for key, value in extra_vars.iteritems(): for key, value in extra_vars.iteritems():
if not key.startswith('ansible_'): if not key.startswith('ansible_'):
extra_vars_redacted[key] = value extra_vars_redacted[key] = value
return extra_vars_redacted return extra_vars_redacted

View File

@@ -44,6 +44,7 @@ PARAM_NAMES = {
'indv_facts': 'LOG_AGGREGATOR_INDIVIDUAL_FACTS', 'indv_facts': 'LOG_AGGREGATOR_INDIVIDUAL_FACTS',
'enabled_flag': 'LOG_AGGREGATOR_ENABLED', 'enabled_flag': 'LOG_AGGREGATOR_ENABLED',
'tcp_timeout': 'LOG_AGGREGATOR_TCP_TIMEOUT', 'tcp_timeout': 'LOG_AGGREGATOR_TCP_TIMEOUT',
'verify_cert': 'LOG_AGGREGATOR_VERIFY_CERT'
} }
@@ -242,8 +243,11 @@ class BaseHTTPSHandler(BaseHandler):
payload_str = json.dumps(payload_input) payload_str = json.dumps(payload_input)
else: else:
payload_str = payload_input payload_str = payload_input
return dict(data=payload_str, background_callback=unused_callback, kwargs = dict(data=payload_str, background_callback=unused_callback,
timeout=self.tcp_timeout) timeout=self.tcp_timeout)
if self.verify_cert is False:
kwargs['verify'] = False
return kwargs
def _send(self, payload): def _send(self, payload):

View File

@@ -14,12 +14,12 @@ from celery import current_app
logger = logging.getLogger('awx.main.utils.reload') logger = logging.getLogger('awx.main.utils.reload')
def _uwsgi_reload(): def _uwsgi_fifo_command(uwsgi_command):
# http://uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html#available-commands # http://uwsgi-docs.readthedocs.io/en/latest/MasterFIFO.html#available-commands
logger.warn('Initiating uWSGI chain reload of server') logger.warn('Initiating uWSGI chain reload of server')
TRIGGER_CHAIN_RELOAD = 'c' TRIGGER_COMMAND = uwsgi_command
with open(settings.UWSGI_FIFO_LOCATION, 'w') as awxfifo: with open(settings.UWSGI_FIFO_LOCATION, 'w') as awxfifo:
awxfifo.write(TRIGGER_CHAIN_RELOAD) awxfifo.write(TRIGGER_COMMAND)
def _reset_celery_thread_pool(): def _reset_celery_thread_pool():
@@ -29,7 +29,7 @@ def _reset_celery_thread_pool():
destination=['celery@{}'.format(settings.CLUSTER_HOST_ID)], reply=False) destination=['celery@{}'.format(settings.CLUSTER_HOST_ID)], reply=False)
def _supervisor_service_restart(service_internal_names): def _supervisor_service_command(service_internal_names, command):
''' '''
Service internal name options: Service internal name options:
- beat - celery - callback - channels - uwsgi - daphne - beat - celery - callback - channels - uwsgi - daphne
@@ -46,23 +46,35 @@ def _supervisor_service_restart(service_internal_names):
for n in service_internal_names: for n in service_internal_names:
if n in name_translation_dict: if n in name_translation_dict:
programs.append('{}:{}'.format(group_name, name_translation_dict[n])) programs.append('{}:{}'.format(group_name, name_translation_dict[n]))
args.extend(['restart']) args.extend([command])
args.extend(programs) args.extend(programs)
logger.debug('Issuing command to restart services, args={}'.format(args)) logger.debug('Issuing command to restart services, args={}'.format(args))
subprocess.Popen(args) supervisor_process = subprocess.Popen(args, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
restart_stdout, restart_err = supervisor_process.communicate()
restart_code = supervisor_process.returncode
if restart_code or restart_err:
logger.error('supervisorctl restart errored with exit code `{}`, stdout:\n{}stderr:\n{}'.format(
restart_code, restart_stdout.strip(), restart_err.strip()))
else:
logger.info('supervisorctl restart finished, stdout:\n{}'.format(restart_stdout.strip()))
def restart_local_services(service_internal_names): def restart_local_services(service_internal_names):
logger.warn('Restarting services {} on this node in response to user action'.format(service_internal_names)) logger.warn('Restarting services {} on this node in response to user action'.format(service_internal_names))
if 'uwsgi' in service_internal_names: if 'uwsgi' in service_internal_names:
_uwsgi_reload() _uwsgi_fifo_command(uwsgi_command='c')
service_internal_names.remove('uwsgi') service_internal_names.remove('uwsgi')
restart_celery = False restart_celery = False
if 'celery' in service_internal_names: if 'celery' in service_internal_names:
restart_celery = True restart_celery = True
service_internal_names.remove('celery') service_internal_names.remove('celery')
_supervisor_service_restart(service_internal_names) _supervisor_service_command(service_internal_names, command='restart')
if restart_celery: if restart_celery:
# Celery restarted last because this probably includes current process # Celery restarted last because this probably includes current process
_reset_celery_thread_pool() _reset_celery_thread_pool()
def stop_local_services(service_internal_names):
logger.warn('Stopping services {} on this node in response to user action'.format(service_internal_names))
_supervisor_service_command(service_internal_names, command='stop')

View File

@@ -10,20 +10,32 @@ from django.utils.translation import ugettext_lazy as _
from rest_framework import exceptions, permissions, views from rest_framework import exceptions, permissions, views
def _force_raising_exception(view_obj, request, format=None):
raise view_obj.exception_class()
class ApiErrorView(views.APIView): class ApiErrorView(views.APIView):
authentication_classes = [] authentication_classes = []
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
metadata_class = None metadata_class = None
allowed_methods = ('GET', 'HEAD')
exception_class = exceptions.APIException exception_class = exceptions.APIException
view_name = _('API Error') view_name = _('API Error')
def get_view_name(self): def get_view_name(self):
return self.view_name return self.view_name
def get(self, request, format=None): def finalize_response(self, request, response, *args, **kwargs):
raise self.exception_class() response = super(ApiErrorView, self).finalize_response(request, response, *args, **kwargs)
try:
del response['Allow']
except Exception:
pass
return response
for method_name in ApiErrorView.http_method_names:
setattr(ApiErrorView, method_name, _force_raising_exception)
def handle_error(request, status=404, **kwargs): def handle_error(request, status=404, **kwargs):

View File

@@ -128,8 +128,8 @@
url_password: "{{scm_password}}" url_password: "{{scm_password}}"
force_basic_auth: yes force_basic_auth: yes
force: yes force: yes
when: scm_type == 'insights' and item.name != None when: scm_type == 'insights' and item.name != None and item.name != ""
with_items: "{{insights_output.json}}" with_items: "{{ insights_output.json|default([]) }}"
failed_when: false failed_when: false
- name: Fetch Insights Playbook - name: Fetch Insights Playbook
@@ -140,8 +140,8 @@
url_password: "{{scm_password}}" url_password: "{{scm_password}}"
force_basic_auth: yes force_basic_auth: yes
force: yes force: yes
when: scm_type == 'insights' and item.name == None when: scm_type == 'insights' and (item.name == None or item.name == "")
with_items: "{{insights_output.json}}" with_items: "{{ insights_output.json|default([]) }}"
failed_when: false failed_when: false
- name: detect requirements.yml - name: detect requirements.yml

View File

@@ -876,8 +876,10 @@ INSIGHTS_URL_BASE = "https://access.redhat.com"
TOWER_SETTINGS_MANIFEST = {} TOWER_SETTINGS_MANIFEST = {}
# Settings related to external logger configuration
LOG_AGGREGATOR_ENABLED = False LOG_AGGREGATOR_ENABLED = False
LOG_AGGREGATOR_TCP_TIMEOUT = 5 LOG_AGGREGATOR_TCP_TIMEOUT = 5
LOG_AGGREGATOR_VERIFY_CERT = True
# The number of retry attempts for websocket session establishment # The number of retry attempts for websocket session establishment
# If you're encountering issues establishing websockets in clustered Tower, # If you're encountering issues establishing websockets in clustered Tower,

View File

@@ -12,7 +12,6 @@ from django.dispatch import receiver
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.conf import settings as django_settings from django.conf import settings as django_settings
from django.core.signals import setting_changed from django.core.signals import setting_changed
from django.core.exceptions import ImproperlyConfigured
# django-auth-ldap # django-auth-ldap
from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings
@@ -93,8 +92,8 @@ class LDAPBackend(BaseLDAPBackend):
return None return None
try: try:
return super(LDAPBackend, self).authenticate(username, password) return super(LDAPBackend, self).authenticate(username, password)
except ImproperlyConfigured: except Exception:
logger.error("Unable to authenticate, LDAP is improperly configured") logger.exception("Encountered an error authenticating to LDAP")
return None return None
def get_user(self, user_id): def get_user(self, user_id):

View File

@@ -353,7 +353,6 @@
.select2-results__option{ .select2-results__option{
color: @field-label !important; color: @field-label !important;
height: 30px!important;
} }
.select2-container--default .select2-results__option--highlighted[aria-selected]{ .select2-container--default .select2-results__option--highlighted[aria-selected]{
@@ -692,3 +691,15 @@ input[type='radio']:checked:before {
.alert-info--noTextTransform { .alert-info--noTextTransform {
text-transform: none; text-transform: none;
} }
.select2-results__option {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.select2-results__option:hover {
overflow-wrap: break-word;
white-space: normal;
}

View File

@@ -41,6 +41,7 @@
<div class="Form-tab" <div class="Form-tab"
ng-click="selectTab('workflow_templates')" ng-click="selectTab('workflow_templates')"
ng-class="{'is-selected': tab.workflow_templates}" ng-class="{'is-selected': tab.workflow_templates}"
ng-hide="resolve.workflowTemplatesDataset.status === 402"
translate> translate>
Workflow Templates Workflow Templates
</div> </div>
@@ -72,7 +73,7 @@
<div id="AddPermissions-jobTemplates" class="AddPermissions-list" ng-show="tab.job_templates"> <div id="AddPermissions-jobTemplates" class="AddPermissions-list" ng-show="tab.job_templates">
<rbac-multiselect-list view="JobTemplates" all-selected="allSelected" dataset="resolve.jobTemplatesDataset"></rbac-multiselect-list> <rbac-multiselect-list view="JobTemplates" all-selected="allSelected" dataset="resolve.jobTemplatesDataset"></rbac-multiselect-list>
</div> </div>
<div id="AddPermissions-workflowTemplates" class="AddPermissions-list" ng-show="tab.workflow_templates"> <div ng-if="resolve.workflowTemplatesDataset.status !== 402" id="AddPermissions-workflowTemplates" class="AddPermissions-list" ng-show="tab.workflow_templates">
<rbac-multiselect-list view="WorkflowTemplates" all-selected="allSelected" dataset="resolve.workflowTemplatesDataset"></rbac-multiselect-list> <rbac-multiselect-list view="WorkflowTemplates" all-selected="allSelected" dataset="resolve.workflowTemplatesDataset"></rbac-multiselect-list>
</div> </div>
<div id="AddPermissions-projects" class="AddPermissions-list" ng-show="tab.projects"> <div id="AddPermissions-projects" class="AddPermissions-list" ng-show="tab.projects">

View File

@@ -26,8 +26,18 @@ export default ['i18n', function(i18n) {
}, },
AUTH_LDAP_USER_SEARCH: { AUTH_LDAP_USER_SEARCH: {
type: 'textarea', type: 'textarea',
rows: 6,
codeMirror: true,
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
reset: 'AUTH_LDAP_USER_SEARCH' reset: 'AUTH_LDAP_USER_SEARCH'
}, },
AUTH_LDAP_GROUP_SEARCH: {
type: 'textarea',
rows: 6,
codeMirror: true,
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
reset: 'AUTH_LDAP_GROUP_SEARCH'
},
AUTH_LDAP_USER_DN_TEMPLATE: { AUTH_LDAP_USER_DN_TEMPLATE: {
type: 'text', type: 'text',
reset: 'AUTH_LDAP_USER_DN_TEMPLATE' reset: 'AUTH_LDAP_USER_DN_TEMPLATE'
@@ -39,10 +49,6 @@ export default ['i18n', function(i18n) {
codeMirror: true, codeMirror: true,
class: 'Form-textAreaLabel Form-formGroup--fullWidth', class: 'Form-textAreaLabel Form-formGroup--fullWidth',
}, },
AUTH_LDAP_GROUP_SEARCH: {
type: 'textarea',
reset: 'AUTH_LDAP_GROUP_SEARCH'
},
AUTH_LDAP_GROUP_TYPE: { AUTH_LDAP_GROUP_TYPE: {
type: 'select', type: 'select',
reset: 'AUTH_LDAP_GROUP_TYPE', reset: 'AUTH_LDAP_GROUP_TYPE',

View File

@@ -75,7 +75,10 @@ export default [
if(key === "AD_HOC_COMMANDS"){ if(key === "AD_HOC_COMMANDS"){
$scope[key] = data[key].toString(); $scope[key] = data[key].toString();
} }
else{ else if(key === "AUTH_LDAP_USER_SEARCH" || key === "AUTH_LDAP_GROUP_SEARCH"){
$scope[key] = JSON.stringify(data[key]);
}
else {
$scope[key] = ConfigurationUtils.arrayToList(data[key], key); $scope[key] = ConfigurationUtils.arrayToList(data[key], key);
} }
@@ -353,27 +356,38 @@ export default [
clearApiErrors(); clearApiErrors();
_.each(keys, function(key) { _.each(keys, function(key) {
if($scope.configDataResolve[key].type === 'choice' || multiselectDropdowns.indexOf(key) !== -1) { if($scope.configDataResolve[key].type === 'choice' || multiselectDropdowns.indexOf(key) !== -1) {
// Handle AD_HOC_COMMANDS
if(multiselectDropdowns.indexOf(key) !== -1) {
let newModules = $("#configuration_jobs_template_AD_HOC_COMMANDS > option")
.filter("[data-select2-tag=true]")
.map((i, val) => ({value: $(val).text()}));
newModules.each(function(i, val) {
$scope[key].push(val);
});
payload[key] = ConfigurationUtils.listToArray(_.map($scope[key], 'value').join(','));
}
//Parse dropdowns and dropdowns labeled as lists //Parse dropdowns and dropdowns labeled as lists
if($scope[key] === null) { else if($scope[key] === null) {
payload[key] = null; payload[key] = null;
} else if($scope[key][0] && $scope[key][0].value !== undefined) { } else if($scope[key][0] && $scope[key][0].value !== undefined) {
if(multiselectDropdowns.indexOf(key) !== -1) { payload[key] = _.map($scope[key], 'value').join(',');
// Handle AD_HOC_COMMANDS
payload[key] = ConfigurationUtils.listToArray(_.map($scope[key], 'value').join(','));
} else {
payload[key] = _.map($scope[key], 'value').join(',');
}
} else { } else {
if(multiselectDropdowns.indexOf(key) !== -1) { payload[key] = $scope[key].value;
// Default AD_HOC_COMMANDS to an empty list
payload[key] = $scope[key].value || [];
} else {
payload[key] = $scope[key].value;
}
} }
} else if($scope.configDataResolve[key].type === 'list' && $scope[key] !== null) { } else if($scope.configDataResolve[key].type === 'list' && $scope[key] !== null) {
// Parse lists
payload[key] = ConfigurationUtils.listToArray($scope[key], key); if(key === "AUTH_LDAP_USER_SEARCH" || key === "AUTH_LDAP_GROUP_SEARCH"){
payload[key] = $scope[key] === "{}" ? [] : ToJSON($scope.parseType,
$scope[key]);
}
else {
// Parse lists
payload[key] = ConfigurationUtils.listToArray($scope[key], key);
}
} }
else if($scope.configDataResolve[key].type === 'nested object') { else if($scope.configDataResolve[key].type === 'nested object') {
if($scope[key] === '') { if($scope[key] === '') {

View File

@@ -79,7 +79,18 @@ export default [
function populateAdhocCommand(flag){ function populateAdhocCommand(flag){
$scope.$parent.AD_HOC_COMMANDS = $scope.$parent.AD_HOC_COMMANDS.toString(); $scope.$parent.AD_HOC_COMMANDS = $scope.$parent.AD_HOC_COMMANDS.toString();
var ad_hoc_commands = $scope.$parent.AD_HOC_COMMANDS.split(','); var ad_hoc_commands = $scope.$parent.AD_HOC_COMMANDS.split(',');
$scope.$parent.AD_HOC_COMMANDS = _.map(ad_hoc_commands, (item) => _.find($scope.$parent.AD_HOC_COMMANDS_options, { value: item })); $scope.$parent.AD_HOC_COMMANDS = _.map(ad_hoc_commands, function(item){
let option = _.find($scope.$parent.AD_HOC_COMMANDS_options, { value: item });
if(!option){
option = {
name: item,
value: item,
label: item
};
$scope.$parent.AD_HOC_COMMANDS_options.push(option);
}
return option;
});
if(flag !== undefined){ if(flag !== undefined){
dropdownRendered = flag; dropdownRendered = flag;
@@ -90,6 +101,7 @@ export default [
CreateSelect2({ CreateSelect2({
element: '#configuration_jobs_template_AD_HOC_COMMANDS', element: '#configuration_jobs_template_AD_HOC_COMMANDS',
multiple: true, multiple: true,
addNew: true,
placeholder: i18n._('Select commands') placeholder: i18n._('Select commands')
}); });
} }

View File

@@ -11,11 +11,9 @@
$scope.item = group; $scope.item = group;
$scope.submitMode = $stateParams.groups === undefined ? 'move' : 'copy'; $scope.submitMode = $stateParams.groups === undefined ? 'move' : 'copy';
$scope.toggle_row = function(id){
// toggle off anything else currently selected $scope.updateSelected = function(selectedGroup) {
_.forEach($scope.groups, (item) => {return item.id === id ? item.checked = 1 : item.checked = null;}); $scope.selected = angular.copy(selectedGroup);
// yoink the currently selected thing
$scope.selected = _.find($scope.groups, (item) => {return item.id === id;});
}; };
$scope.formCancel = function(){ $scope.formCancel = function(){
$state.go('^'); $state.go('^');

View File

@@ -11,11 +11,9 @@
$scope.item = host; $scope.item = host;
$scope.submitMode = 'copy'; $scope.submitMode = 'copy';
$scope.toggle_row = function(id){
// toggle off anything else currently selected $scope.updateSelected = function(selectedGroup) {
_.forEach($scope.groups, (item) => {return item.id === id ? item.checked = 1 : item.checked = null;}); $scope.selected = angular.copy(selectedGroup);
// yoink the currently selected thing
$scope.selected = _.find($scope.groups, (item) => {return item.id === id;});
}; };
$scope.formCancel = function(){ $scope.formCancel = function(){
$state.go('^'); $state.go('^');

View File

@@ -0,0 +1,44 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
['$scope', 'CopyMoveGroupList', 'Dataset',
function($scope, list, Dataset){
init();
function init() {
$scope.list = list;
$scope[`${list.iterator}_dataset`] = Dataset.data;
$scope[list.name] = $scope[`${list.iterator}_dataset`].results;
$scope.$watch('groups', function(){
if($scope.selectedGroup){
$scope.groups.forEach(function(row, i) {
if(row.id === $scope.selectedGroup.id) {
$scope.groups[i].checked = 1;
}
else {
$scope.groups[i].checked = 0;
}
});
}
});
}
$scope.toggle_row = function(id) {
// toggle off anything else currently selected
_.forEach($scope.groups, (item) => {
if(item.id === id) {
item.checked = 1;
$scope.selectedGroup = item;
$scope.updateSelected(item);
}
else {
item.checked = null;
}
});
};
}];

View File

@@ -8,6 +8,7 @@ import { N_ } from '../../../i18n';
import CopyMoveGroupsController from './copy-move-groups.controller'; import CopyMoveGroupsController from './copy-move-groups.controller';
import CopyMoveHostsController from './copy-move-hosts.controller'; import CopyMoveHostsController from './copy-move-hosts.controller';
import CopyMoveListController from './copy-move-list.controller';
var copyMoveGroupRoute = { var copyMoveGroupRoute = {
name: 'inventoryManage.copyMoveGroup', name: 'inventoryManage.copyMoveGroup',
@@ -53,7 +54,8 @@ var copyMoveGroupRoute = {
input_type: 'radio' input_type: 'radio'
}); });
return html; return html;
} },
controller: CopyMoveListController
} }
} }
}; };
@@ -90,7 +92,8 @@ var copyMoveHostRoute = {
input_type: 'radio' input_type: 'radio'
}); });
return html; return html;
} },
controller: CopyMoveListController
} }
} }
}; };

View File

@@ -258,11 +258,7 @@ export default ['$log', 'moment', function($log, moment){
.split("\r\n"); .split("\r\n");
if (lineNums.length > lines.length) { if (lineNums.length > lines.length) {
let padBy = lineNums.length - lines.length; lineNums = lineNums.slice(0, lines.length);
for (let i = 0; i <= padBy; i++) {
lines.push("");
}
} }
lines = this.distributeColors(lines); lines = this.distributeColors(lines);
@@ -293,7 +289,7 @@ export default ['$log', 'moment', function($log, moment){
return ` return `
<div class="JobResultsStdOut-aLineOfStdOut${this.getLineClasses(event, lineArr[1], lineArr[0])}"> <div class="JobResultsStdOut-aLineOfStdOut${this.getLineClasses(event, lineArr[1], lineArr[0])}">
<div class="JobResultsStdOut-lineNumberColumn">${this.getCollapseIcon(event, lineArr[1])}${lineArr[0]}</div> <div class="JobResultsStdOut-lineNumberColumn">${this.getCollapseIcon(event, lineArr[1])}${lineArr[0]}</div>
<div class="JobResultsStdOut-stdoutColumn${this.getAnchorTags(event)}>${this.prettify(lineArr[1])} ${this.getStartTimeBadge(event, lineArr[1])}</div> <div class="JobResultsStdOut-stdoutColumn${this.getAnchorTags(event)}><span ng-non-bindable>${this.prettify(lineArr[1])}</span>${this.getStartTimeBadge(event, lineArr[1])}</div>
</div>`; </div>`;
}); });

View File

@@ -0,0 +1,29 @@
/*************************************************
* Copyright (c) 2017 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/* jshint unused: vars */
export default
[ 'Empty',
function(Empty) {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, elem, attr, ctrl) {
ctrl.$parsers.unshift(function(viewValue) {
var max = (attr.awPasswordMax) ? scope.$eval(attr.awPasswordMax) : Infinity;
if (!Empty(max) && !Empty(viewValue) && viewValue.length > max && viewValue !== '$encrypted$') {
ctrl.$setValidity('awPasswordMax', false);
return viewValue;
} else {
ctrl.$setValidity('awPasswordMax', true);
return viewValue;
}
});
}
};
}
];

View File

@@ -0,0 +1,27 @@
/*************************************************
* Copyright (c) 2017 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
[ 'Empty',
function(Empty) {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, elem, attr, ctrl) {
ctrl.$parsers.unshift(function(viewValue) {
var min = (attr.awPasswordMin) ? scope.$eval(attr.awPasswordMin) : -Infinity;
if (!Empty(min) && !Empty(viewValue) && viewValue.length < min && viewValue !== '$encrypted$') {
ctrl.$setValidity('awPasswordMin', false);
return viewValue;
} else {
ctrl.$setValidity('awPasswordMin', true);
return viewValue;
}
});
}
};
}
];

View File

@@ -42,6 +42,13 @@ export default
else if(question.type === "multiplechoice") { else if(question.type === "multiplechoice") {
question.model = question.default ? angular.copy(question.default) : ""; question.model = question.default ? angular.copy(question.default) : "";
question.choices = question.choices.split(/\n/); question.choices = question.choices.split(/\n/);
// Add a default empty string option to the choices array. If this choice is
// selected then the extra var will not be sent when we POST to the launch
// endpoint
if(!question.required) {
question.choices.unshift('');
}
} }
else if(question.type === "float"){ else if(question.type === "float"){
question.model = (!Empty(question.default)) ? angular.copy(question.default) : (!Empty(question.default_float)) ? angular.copy(question.default_float) : ""; question.model = (!Empty(question.default)) ? angular.copy(question.default) : (!Empty(question.default_float)) ? angular.copy(question.default_float) : "";

View File

@@ -90,9 +90,9 @@ export default
// for optional select lists, if they are left blank make sure we submit // for optional select lists, if they are left blank make sure we submit
// a value that the API will consider "empty" // a value that the API will consider "empty"
// //
case "multiplechoice": // ISSUE: I don't think this logic ever actually fires
job_launch_data.extra_vars[fld] = ""; // When I tested this, we don't pass this extra var back
break; // through the api when the mutliselect is optional and empty
case "multiselect": case "multiselect":
job_launch_data.extra_vars[fld] = []; job_launch_data.extra_vars[fld] = [];
break; break;

View File

@@ -186,10 +186,11 @@
<span class="input-group-btn"> <span class="input-group-btn">
<button type="button" class="btn btn-default show_input_button JobSubmission-passwordButton" id="survey_question_{{$index}}_show_input_button" aw-tool-tip="Toggle the display of plaintext." aw-tip-placement="top" ng-click="togglePassword('#survey_question_' + $index)" data-original-title="" title="">Show</button> <button type="button" class="btn btn-default show_input_button JobSubmission-passwordButton" id="survey_question_{{$index}}_show_input_button" aw-tool-tip="Toggle the display of plaintext." aw-tip-placement="top" ng-click="togglePassword('#survey_question_' + $index)" data-original-title="" title="">Show</button>
</span> </span>
<input id="survey_question_{{$index}}" type="password" ng-model="question.model" name="survey_question_{{$index}}" ng-required="question.required" ng-minlength="question.minlength" ng-maxlength="question.maxlength" class="form-control Form-textInput" autocomplete="false"> <input id="survey_question_{{$index}}" ng-if="!question.default" type="password" ng-model="question.model" name="survey_question_{{$index}}" ng-required="question.required" ng-minlength="question.minlength" ng-maxlength="question.maxlength" class="form-control Form-textInput" autocomplete="false">
<input id="survey_question_{{$index}}" ng-if="question.default" type="password" ng-model="question.model" name="survey_question_{{$index}}" ng-required="question.required" aw-password-min="question.minlength" aw-password-max="question.maxlength" class="form-control Form-textInput" autocomplete="false">
</div> </div>
<div class="error survey_error" ng-show="forms.survey.survey_question_{{$index}}.$dirty && forms.survey.survey_question_{{$index}}.$error.required">Please enter an answer.</div> <div class="error survey_error" ng-show="forms.survey.survey_question_{{$index}}.$dirty && forms.survey.survey_question_{{$index}}.$error.required">Please enter an answer.</div>
<div class="error survey_error" ng-show="forms.survey.survey_question_{{$index}}.$error.minlength || forms.survey.survey_question_{{$index}}.$error.maxlength">Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.</div> <div class="error survey_error" ng-show="forms.survey.survey_question_{{$index}}.$error.awPasswordMin || forms.survey.survey_question_{{$index}}.$error.awPasswordMax || forms.survey.survey_question_{{$index}}.$error.minlength || forms.survey.survey_question_{{$index}}.$error.maxlength">Please enter an answer between {{question.minlength}} to {{question.maxlength}} characters long.</div>
</div> </div>
<div ng-if="question.type === 'integer'"> <div ng-if="question.type === 'integer'">
<input type="number" id="survey_question_{{$index}}" ng-model="question.model" class="form-control Form-textInput" name="survey_question_{{$index}}" ng-required="question.required" integer aw-min="question.minValue" aw-max="question.maxValue"/> <input type="number" id="survey_question_{{$index}}" ng-model="question.model" class="form-control Form-textInput" name="survey_question_{{$index}}" ng-required="question.required" integer aw-min="question.minValue" aw-max="question.maxValue"/>

View File

@@ -16,6 +16,8 @@ import PromptForPasswords from './job-submission-factories/prompt-for-passwords.
import submitJob from './job-submission.directive'; import submitJob from './job-submission.directive';
import credentialList from './lists/credential/job-sub-cred-list.directive'; import credentialList from './lists/credential/job-sub-cred-list.directive';
import inventoryList from './lists/inventory/job-sub-inv-list.directive'; import inventoryList from './lists/inventory/job-sub-inv-list.directive';
import awPasswordMin from './job-submission-directives/aw-password-min.directive';
import awPasswordMax from './job-submission-directives/aw-password-max.directive';
export default export default
angular.module('jobSubmission', []) angular.module('jobSubmission', [])
@@ -30,4 +32,6 @@ export default
.factory('PromptForPasswords', PromptForPasswords) .factory('PromptForPasswords', PromptForPasswords)
.directive('submitJob', submitJob) .directive('submitJob', submitJob)
.directive('jobSubCredList', credentialList) .directive('jobSubCredList', credentialList)
.directive('jobSubInvList', inventoryList); .directive('jobSubInvList', inventoryList)
.directive('awPasswordMin', awPasswordMin)
.directive('awPasswordMax', awPasswordMax);

View File

@@ -1387,7 +1387,7 @@ function(ConfigurationUtils, i18n, $rootScope) {
} }
} }
else { else {
return false; return (!value || value === "") ? false : true;
} }
}; };
} }

View File

@@ -254,6 +254,10 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear
.catch(function(response) { .catch(function(response) {
Wait('stop'); Wait('stop');
if (/^\/api\/v[0-9]+\/workflow_job_templates\/$/.test(endpoint) && response.status === 402) {
return response;
}
this.error(response.data, response.status); this.error(response.data, response.status);
throw response; throw response;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -129,7 +129,7 @@ describe('parseStdoutService', () => {
end_line: 11, end_line: 11,
stdout: "a\r\nb\r\nc..." stdout: "a\r\nb\r\nc..."
}; };
let expectedReturn = [[8, "a"],[9, "b"], [10,"c..."], [11, ""]]; let expectedReturn = [[8, "a"],[9, "b"], [10,"c..."]];
let returnedEvent = parseStdoutService.getLineArr(mockEvent); let returnedEvent = parseStdoutService.getLineArr(mockEvent);
@@ -204,7 +204,7 @@ describe('parseStdoutService', () => {
var expectedString = ` var expectedString = `
<div class="JobResultsStdOut-aLineOfStdOutline_classes"> <div class="JobResultsStdOut-aLineOfStdOutline_classes">
<div class="JobResultsStdOut-lineNumberColumn">collapse_icon_dom13</div> <div class="JobResultsStdOut-lineNumberColumn">collapse_icon_dom13</div>
<div class="JobResultsStdOut-stdoutColumn" anchor_tag_dom>prettified_line </div> <div class="JobResultsStdOut-stdoutColumn" anchor_tag_dom><span ng-non-bindable>prettified_line</span></div>
</div>`; </div>`;
expect(returnedString).toBe(expectedString); expect(returnedString).toBe(expectedString);
}); });