mirror of
https://github.com/ansible/awx.git
synced 2026-01-11 01:57:35 -03:30
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:
commit
e1e83598e9
18
Makefile
18
Makefile
@ -410,13 +410,13 @@ uwsgi: collectstatic
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
. $(VENV_BASE)/tower/bin/activate; \
|
||||
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:
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
. $(VENV_BASE)/tower/bin/activate; \
|
||||
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:
|
||||
@if [ "$(VENV_BASE)" ]; then \
|
||||
@ -702,13 +702,19 @@ rpm-build:
|
||||
mkdir -p $@
|
||||
|
||||
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.fc rpm-build/
|
||||
cp packaging/rpm/$(NAME).sysconfig rpm-build/
|
||||
cp packaging/remove_tower_source.py rpm-build/
|
||||
cp packaging/bytecompile.sh rpm-build/
|
||||
cp tar-build/$(SETUP_TAR_FILE) rpm-build/
|
||||
|
||||
if [ "$(OFFICIAL)" != "yes" ] ; then \
|
||||
(cd dist/ && tar zxf $(SDIST_TAR_FILE)) ; \
|
||||
(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' > $@
|
||||
|
||||
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 \
|
||||
--define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES)
|
||||
$(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --buildsrpm --spec rpm-build/$(NAME).spec --sources rpm-build
|
||||
|
||||
mock-srpm: rpmtar rpm-build/$(RPM_NVR).src.rpm
|
||||
@echo "#############################################"
|
||||
@ -776,8 +781,7 @@ mock-srpm: rpmtar rpm-build/$(RPM_NVR).src.rpm
|
||||
brew-srpm: brewrpmtar mock-srpm
|
||||
|
||||
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 \
|
||||
--define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES)
|
||||
$(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --rebuild rpm-build/$(RPM_NVR).src.rpm
|
||||
|
||||
mock-rpm: rpmtar rpm-build/$(RPM_NVR).$(RPM_ARCH).rpm
|
||||
@echo "#############################################"
|
||||
|
||||
@ -234,7 +234,8 @@ class ApiV1PingView(APIView):
|
||||
|
||||
response['instances'] = []
|
||||
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()
|
||||
return Response(response)
|
||||
|
||||
|
||||
@ -31,6 +31,16 @@ class CharField(CharField):
|
||||
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):
|
||||
|
||||
child = CharField()
|
||||
|
||||
@ -86,7 +86,19 @@ def executor(tmpdir_factory, request):
|
||||
- name: Hello Message
|
||||
debug:
|
||||
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):
|
||||
executor.run()
|
||||
|
||||
@ -63,7 +63,6 @@ class BaseCallbackModule(CallbackBase):
|
||||
|
||||
@contextlib.contextmanager
|
||||
def capture_event_data(self, event, **event_data):
|
||||
|
||||
event_data.setdefault('uuid', str(uuid.uuid4()))
|
||||
|
||||
if event not in self.EVENTS_WITHOUT_TASK:
|
||||
@ -75,7 +74,7 @@ class BaseCallbackModule(CallbackBase):
|
||||
if event_data['res'].get('_ansible_no_log', False):
|
||||
event_data['res'] = {'censored': CENSORED}
|
||||
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}
|
||||
|
||||
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
@ -61,12 +61,15 @@ class FactBrokerWorker(ConsumerMixin):
|
||||
host_obj = Host.objects.get(name=hostname, inventory__id=inventory_id)
|
||||
except Fact.DoesNotExist:
|
||||
logger.warn('Failed to intake fact. Host does not exist <hostname, inventory_id> <%s, %s>' % (hostname, inventory_id))
|
||||
message.ack()
|
||||
return
|
||||
except Fact.MultipleObjectsReturned:
|
||||
logger.warn('Database inconsistent. Multiple Hosts found for <hostname, inventory_id> <%s, %s>.' % (hostname, inventory_id))
|
||||
message.ack()
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error("Exception communicating with Fact Cache Database: %s" % str(e))
|
||||
message.ack()
|
||||
return None
|
||||
|
||||
(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))
|
||||
analytics_logger.info('Received message with fact data', extra=dict(
|
||||
module_name=module_name, facts_data=facts))
|
||||
message.ack()
|
||||
return fact_obj
|
||||
|
||||
|
||||
|
||||
19
awx/main/migrations/0037_v313_instance_version.py
Normal file
19
awx/main/migrations/0037_v313_instance_version.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@ -26,6 +26,7 @@ class Instance(models.Model):
|
||||
hostname = models.CharField(max_length=250, unique=True)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
modified = models.DateTimeField(auto_now=True)
|
||||
version = models.CharField(max_length=24, blank=True)
|
||||
capacity = models.PositiveIntegerField(
|
||||
default=100,
|
||||
editable=False,
|
||||
|
||||
@ -596,7 +596,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin):
|
||||
playbook=self.playbook,
|
||||
credential=self.credential.name if self.credential else None,
|
||||
limit=self.limit,
|
||||
extra_vars=self.extra_vars,
|
||||
extra_vars=self.display_extra_vars(),
|
||||
hosts=all_hosts))
|
||||
return data
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
# Python
|
||||
import json
|
||||
from copy import copy
|
||||
|
||||
# Django
|
||||
from django.db import models
|
||||
@ -28,9 +29,7 @@ class ResourceMixin(models.Model):
|
||||
'''
|
||||
Use instead of `MyModel.objects` when you want to only consider
|
||||
resources that a user has specific permissions for. For example:
|
||||
|
||||
MyModel.accessible_objects(user, 'read_role').filter(name__istartswith='bar');
|
||||
|
||||
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
|
||||
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']],
|
||||
survey_element['variable']))
|
||||
return errors
|
||||
if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']):
|
||||
errors.append("'%s' value %s is too small (length is %s must be at least %s)." %
|
||||
(survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min']))
|
||||
if 'max' in survey_element and survey_element['max'] not in ["", None] and len(data[survey_element['variable']]) > int(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']))
|
||||
if not data[survey_element['variable']] == '$encrypted$' and not survey_element['type'] == 'password':
|
||||
if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']):
|
||||
errors.append("'%s' value %s is too small (length is %s must be at least %s)." %
|
||||
(survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min']))
|
||||
if 'max' in survey_element and survey_element['max'] not in ["", None] and len(data[survey_element['variable']]) > int(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':
|
||||
if survey_element['variable'] in data:
|
||||
if type(data[survey_element['variable']]) != int:
|
||||
@ -196,16 +196,22 @@ class SurveyJobTemplateMixin(models.Model):
|
||||
if type(data[survey_element['variable']]) != list:
|
||||
errors.append("'%s' value is expected to be a list." % survey_element['variable'])
|
||||
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']]:
|
||||
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'],
|
||||
survey_element['choices']))
|
||||
choice_list))
|
||||
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 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']],
|
||||
survey_element['variable'],
|
||||
survey_element['choices']))
|
||||
choice_list))
|
||||
return errors
|
||||
|
||||
def survey_variable_validation(self, data):
|
||||
|
||||
@ -204,6 +204,12 @@ class ProjectOptions(models.Model):
|
||||
break
|
||||
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):
|
||||
'''
|
||||
|
||||
@ -21,7 +21,9 @@ import traceback
|
||||
import urlparse
|
||||
import uuid
|
||||
from distutils.version import LooseVersion as Version
|
||||
from datetime import timedelta
|
||||
import yaml
|
||||
import fcntl
|
||||
try:
|
||||
import psutil
|
||||
except:
|
||||
@ -46,6 +48,7 @@ from django.core.cache import cache
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# AWX
|
||||
from awx import __version__ as tower_application_version
|
||||
from awx.main.constants import CLOUD_PROVIDERS
|
||||
from awx.main.models import * # noqa
|
||||
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,
|
||||
check_proot_installed, build_proot_temp_dir, wrap_args_with_proot,
|
||||
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.consumers import emit_channel_notification
|
||||
|
||||
@ -175,13 +178,27 @@ def purge_old_stdout_files(self):
|
||||
@task(bind=True)
|
||||
def cluster_node_heartbeat(self):
|
||||
logger.debug("Cluster node heartbeat task.")
|
||||
nowtime = now()
|
||||
inst = Instance.objects.filter(hostname=settings.CLUSTER_HOST_ID)
|
||||
if inst.exists():
|
||||
inst = inst[0]
|
||||
inst.capacity = get_system_task_capacity()
|
||||
inst.version = tower_application_version
|
||||
inst.save()
|
||||
return
|
||||
raise RuntimeError("Cluster Host Not Found: {}".format(settings.CLUSTER_HOST_ID))
|
||||
else:
|
||||
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')
|
||||
@ -1365,7 +1382,45 @@ class RunProjectUpdate(BaseTask):
|
||||
logger.error('Encountered error updating project dependent inventory: {}'.format(e))
|
||||
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):
|
||||
if instance.launch_type == 'sync':
|
||||
self.release_lock(instance)
|
||||
p = instance.project
|
||||
dependent_inventory_sources = p.scm_inventory_sources.all()
|
||||
if instance.job_type == 'check' and status not in ('failed', 'canceled',):
|
||||
|
||||
@ -135,13 +135,15 @@ def create_survey_spec(variables=None, default_type='integer', required=True):
|
||||
argument specifying variable name(s)
|
||||
'''
|
||||
if isinstance(variables, list):
|
||||
name = "%s survey" % variables[0]
|
||||
description = "A survey that starts with %s." % variables[0]
|
||||
vars_list = variables
|
||||
else:
|
||||
name = "%s survey" % variables
|
||||
description = "A survey about %s." % 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 = []
|
||||
index = 0
|
||||
|
||||
@ -2,6 +2,8 @@ from awx.main.models import Job, Instance
|
||||
from django.test.utils import override_settings
|
||||
import pytest
|
||||
|
||||
import json
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
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
|
||||
with override_settings(AWX_ACTIVE_NODE_TIME=0):
|
||||
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
|
||||
|
||||
@ -91,6 +91,40 @@ def test_update_kwargs_survey_invalid_default(survey_spec_factory):
|
||||
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:
|
||||
def test_update_kwargs_survey_defaults(self, survey_spec_factory):
|
||||
"Assure that the survey default over-rides a JT variable"
|
||||
|
||||
@ -5,9 +5,11 @@ import ConfigParser
|
||||
import json
|
||||
import tempfile
|
||||
|
||||
import os
|
||||
import fcntl
|
||||
import pytest
|
||||
import yaml
|
||||
import mock
|
||||
import yaml
|
||||
|
||||
from awx.main.models import (
|
||||
Credential,
|
||||
@ -1067,3 +1069,62 @@ class TestInventoryUpdateCredentials(TestJobExecution):
|
||||
|
||||
self.task.run_pexpect = mock.Mock(side_effect=run_pexpect_side_effect)
|
||||
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'))
|
||||
|
||||
37
awx/main/tests/unit/test_views.py
Normal file
37
awx/main/tests/unit/test_views.py
Normal 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)
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
# Copyright (c) 2017 Ansible, Inc.
|
||||
# All Rights Reserved.
|
||||
import pytest
|
||||
|
||||
from awx.conf.models import Setting
|
||||
from awx.main.utils import common
|
||||
@ -52,3 +53,13 @@ def test_encrypt_field_with_ask():
|
||||
def test_encrypt_field_with_empty_value():
|
||||
encrypted = common.encrypt_field(Setting(value=None), 'value')
|
||||
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
|
||||
|
||||
@ -3,10 +3,15 @@ from awx.main.utils import reload
|
||||
|
||||
|
||||
def test_produce_supervisor_command(mocker):
|
||||
with mocker.patch.object(reload.subprocess, 'Popen'):
|
||||
reload._supervisor_service_restart(['beat', 'callback', 'fact'])
|
||||
communicate_mock = mocker.MagicMock(return_value=('Everything is fine', ''))
|
||||
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(
|
||||
['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):
|
||||
@ -14,13 +19,13 @@ def test_routing_of_service_restarts_works(mocker):
|
||||
This tests that the parent restart method will call the appropriate
|
||||
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, '_supervisor_service_restart'):
|
||||
mocker.patch.object(reload, '_supervisor_service_command'):
|
||||
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._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
|
||||
'''
|
||||
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, '_supervisor_service_restart'):
|
||||
mocker.patch.object(reload, '_supervisor_service_command'):
|
||||
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._supervisor_service_restart.assert_called_once_with(['flower'])
|
||||
reload._supervisor_service_command.assert_called_once_with(['flower'], command="restart")
|
||||
|
||||
|
||||
@ -854,8 +854,8 @@ class OutputEventFilter(object):
|
||||
|
||||
def callback_filter_out_ansible_extra_vars(extra_vars):
|
||||
extra_vars_redacted = {}
|
||||
extra_vars = parse_yaml_or_json(extra_vars)
|
||||
for key, value in extra_vars.iteritems():
|
||||
if not key.startswith('ansible_'):
|
||||
extra_vars_redacted[key] = value
|
||||
return extra_vars_redacted
|
||||
|
||||
|
||||
@ -44,6 +44,7 @@ PARAM_NAMES = {
|
||||
'indv_facts': 'LOG_AGGREGATOR_INDIVIDUAL_FACTS',
|
||||
'enabled_flag': 'LOG_AGGREGATOR_ENABLED',
|
||||
'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)
|
||||
else:
|
||||
payload_str = payload_input
|
||||
return dict(data=payload_str, background_callback=unused_callback,
|
||||
timeout=self.tcp_timeout)
|
||||
kwargs = dict(data=payload_str, background_callback=unused_callback,
|
||||
timeout=self.tcp_timeout)
|
||||
if self.verify_cert is False:
|
||||
kwargs['verify'] = False
|
||||
return kwargs
|
||||
|
||||
|
||||
def _send(self, payload):
|
||||
|
||||
@ -14,12 +14,12 @@ from celery import current_app
|
||||
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
|
||||
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:
|
||||
awxfifo.write(TRIGGER_CHAIN_RELOAD)
|
||||
awxfifo.write(TRIGGER_COMMAND)
|
||||
|
||||
|
||||
def _reset_celery_thread_pool():
|
||||
@ -29,7 +29,7 @@ def _reset_celery_thread_pool():
|
||||
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:
|
||||
- beat - celery - callback - channels - uwsgi - daphne
|
||||
@ -46,23 +46,35 @@ def _supervisor_service_restart(service_internal_names):
|
||||
for n in service_internal_names:
|
||||
if n in name_translation_dict:
|
||||
programs.append('{}:{}'.format(group_name, name_translation_dict[n]))
|
||||
args.extend(['restart'])
|
||||
args.extend([command])
|
||||
args.extend(programs)
|
||||
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):
|
||||
logger.warn('Restarting services {} on this node in response to user action'.format(service_internal_names))
|
||||
if 'uwsgi' in service_internal_names:
|
||||
_uwsgi_reload()
|
||||
_uwsgi_fifo_command(uwsgi_command='c')
|
||||
service_internal_names.remove('uwsgi')
|
||||
restart_celery = False
|
||||
if 'celery' in service_internal_names:
|
||||
restart_celery = True
|
||||
service_internal_names.remove('celery')
|
||||
_supervisor_service_restart(service_internal_names)
|
||||
_supervisor_service_command(service_internal_names, command='restart')
|
||||
if restart_celery:
|
||||
# Celery restarted last because this probably includes current process
|
||||
_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')
|
||||
|
||||
@ -10,20 +10,32 @@ from django.utils.translation import ugettext_lazy as _
|
||||
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):
|
||||
|
||||
authentication_classes = []
|
||||
permission_classes = (permissions.AllowAny,)
|
||||
metadata_class = None
|
||||
allowed_methods = ('GET', 'HEAD')
|
||||
exception_class = exceptions.APIException
|
||||
view_name = _('API Error')
|
||||
|
||||
def get_view_name(self):
|
||||
return self.view_name
|
||||
|
||||
def get(self, request, format=None):
|
||||
raise self.exception_class()
|
||||
def finalize_response(self, request, response, *args, **kwargs):
|
||||
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):
|
||||
|
||||
@ -128,8 +128,8 @@
|
||||
url_password: "{{scm_password}}"
|
||||
force_basic_auth: yes
|
||||
force: yes
|
||||
when: scm_type == 'insights' and item.name != None
|
||||
with_items: "{{insights_output.json}}"
|
||||
when: scm_type == 'insights' and item.name != None and item.name != ""
|
||||
with_items: "{{ insights_output.json|default([]) }}"
|
||||
failed_when: false
|
||||
|
||||
- name: Fetch Insights Playbook
|
||||
@ -140,8 +140,8 @@
|
||||
url_password: "{{scm_password}}"
|
||||
force_basic_auth: yes
|
||||
force: yes
|
||||
when: scm_type == 'insights' and item.name == None
|
||||
with_items: "{{insights_output.json}}"
|
||||
when: scm_type == 'insights' and (item.name == None or item.name == "")
|
||||
with_items: "{{ insights_output.json|default([]) }}"
|
||||
failed_when: false
|
||||
|
||||
- name: detect requirements.yml
|
||||
|
||||
@ -876,8 +876,10 @@ INSIGHTS_URL_BASE = "https://access.redhat.com"
|
||||
|
||||
TOWER_SETTINGS_MANIFEST = {}
|
||||
|
||||
# Settings related to external logger configuration
|
||||
LOG_AGGREGATOR_ENABLED = False
|
||||
LOG_AGGREGATOR_TCP_TIMEOUT = 5
|
||||
LOG_AGGREGATOR_VERIFY_CERT = True
|
||||
|
||||
# The number of retry attempts for websocket session establishment
|
||||
# If you're encountering issues establishing websockets in clustered Tower,
|
||||
|
||||
@ -12,7 +12,6 @@ from django.dispatch import receiver
|
||||
from django.contrib.auth.models import User
|
||||
from django.conf import settings as django_settings
|
||||
from django.core.signals import setting_changed
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
# django-auth-ldap
|
||||
from django_auth_ldap.backend import LDAPSettings as BaseLDAPSettings
|
||||
@ -93,8 +92,8 @@ class LDAPBackend(BaseLDAPBackend):
|
||||
return None
|
||||
try:
|
||||
return super(LDAPBackend, self).authenticate(username, password)
|
||||
except ImproperlyConfigured:
|
||||
logger.error("Unable to authenticate, LDAP is improperly configured")
|
||||
except Exception:
|
||||
logger.exception("Encountered an error authenticating to LDAP")
|
||||
return None
|
||||
|
||||
def get_user(self, user_id):
|
||||
|
||||
@ -353,7 +353,6 @@
|
||||
|
||||
.select2-results__option{
|
||||
color: @field-label !important;
|
||||
height: 30px!important;
|
||||
}
|
||||
|
||||
.select2-container--default .select2-results__option--highlighted[aria-selected]{
|
||||
@ -692,3 +691,15 @@ input[type='radio']:checked:before {
|
||||
.alert-info--noTextTransform {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
<div class="Form-tab"
|
||||
ng-click="selectTab('workflow_templates')"
|
||||
ng-class="{'is-selected': tab.workflow_templates}"
|
||||
ng-hide="resolve.workflowTemplatesDataset.status === 402"
|
||||
translate>
|
||||
Workflow Templates
|
||||
</div>
|
||||
@ -72,7 +73,7 @@
|
||||
<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>
|
||||
</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>
|
||||
</div>
|
||||
<div id="AddPermissions-projects" class="AddPermissions-list" ng-show="tab.projects">
|
||||
|
||||
@ -26,8 +26,18 @@ export default ['i18n', function(i18n) {
|
||||
},
|
||||
AUTH_LDAP_USER_SEARCH: {
|
||||
type: 'textarea',
|
||||
rows: 6,
|
||||
codeMirror: true,
|
||||
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
|
||||
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: {
|
||||
type: 'text',
|
||||
reset: 'AUTH_LDAP_USER_DN_TEMPLATE'
|
||||
@ -39,10 +49,6 @@ export default ['i18n', function(i18n) {
|
||||
codeMirror: true,
|
||||
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
|
||||
},
|
||||
AUTH_LDAP_GROUP_SEARCH: {
|
||||
type: 'textarea',
|
||||
reset: 'AUTH_LDAP_GROUP_SEARCH'
|
||||
},
|
||||
AUTH_LDAP_GROUP_TYPE: {
|
||||
type: 'select',
|
||||
reset: 'AUTH_LDAP_GROUP_TYPE',
|
||||
|
||||
@ -75,7 +75,10 @@ export default [
|
||||
if(key === "AD_HOC_COMMANDS"){
|
||||
$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);
|
||||
}
|
||||
|
||||
@ -353,27 +356,38 @@ export default [
|
||||
clearApiErrors();
|
||||
_.each(keys, function(key) {
|
||||
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
|
||||
if($scope[key] === null) {
|
||||
else if($scope[key] === null) {
|
||||
payload[key] = null;
|
||||
} else if($scope[key][0] && $scope[key][0].value !== undefined) {
|
||||
if(multiselectDropdowns.indexOf(key) !== -1) {
|
||||
// Handle AD_HOC_COMMANDS
|
||||
payload[key] = ConfigurationUtils.listToArray(_.map($scope[key], 'value').join(','));
|
||||
} else {
|
||||
payload[key] = _.map($scope[key], 'value').join(',');
|
||||
}
|
||||
payload[key] = _.map($scope[key], 'value').join(',');
|
||||
} else {
|
||||
if(multiselectDropdowns.indexOf(key) !== -1) {
|
||||
// Default AD_HOC_COMMANDS to an empty list
|
||||
payload[key] = $scope[key].value || [];
|
||||
} else {
|
||||
payload[key] = $scope[key].value;
|
||||
}
|
||||
payload[key] = $scope[key].value;
|
||||
}
|
||||
} 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') {
|
||||
if($scope[key] === '') {
|
||||
|
||||
@ -79,7 +79,18 @@ export default [
|
||||
function populateAdhocCommand(flag){
|
||||
$scope.$parent.AD_HOC_COMMANDS = $scope.$parent.AD_HOC_COMMANDS.toString();
|
||||
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){
|
||||
dropdownRendered = flag;
|
||||
@ -90,6 +101,7 @@ export default [
|
||||
CreateSelect2({
|
||||
element: '#configuration_jobs_template_AD_HOC_COMMANDS',
|
||||
multiple: true,
|
||||
addNew: true,
|
||||
placeholder: i18n._('Select commands')
|
||||
});
|
||||
}
|
||||
|
||||
@ -11,11 +11,9 @@
|
||||
|
||||
$scope.item = group;
|
||||
$scope.submitMode = $stateParams.groups === undefined ? 'move' : 'copy';
|
||||
$scope.toggle_row = function(id){
|
||||
// toggle off anything else currently selected
|
||||
_.forEach($scope.groups, (item) => {return item.id === id ? item.checked = 1 : item.checked = null;});
|
||||
// yoink the currently selected thing
|
||||
$scope.selected = _.find($scope.groups, (item) => {return item.id === id;});
|
||||
|
||||
$scope.updateSelected = function(selectedGroup) {
|
||||
$scope.selected = angular.copy(selectedGroup);
|
||||
};
|
||||
$scope.formCancel = function(){
|
||||
$state.go('^');
|
||||
|
||||
@ -11,11 +11,9 @@
|
||||
|
||||
$scope.item = host;
|
||||
$scope.submitMode = 'copy';
|
||||
$scope.toggle_row = function(id){
|
||||
// toggle off anything else currently selected
|
||||
_.forEach($scope.groups, (item) => {return item.id === id ? item.checked = 1 : item.checked = null;});
|
||||
// yoink the currently selected thing
|
||||
$scope.selected = _.find($scope.groups, (item) => {return item.id === id;});
|
||||
|
||||
$scope.updateSelected = function(selectedGroup) {
|
||||
$scope.selected = angular.copy(selectedGroup);
|
||||
};
|
||||
$scope.formCancel = function(){
|
||||
$state.go('^');
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
};
|
||||
}];
|
||||
@ -8,6 +8,7 @@ import { N_ } from '../../../i18n';
|
||||
|
||||
import CopyMoveGroupsController from './copy-move-groups.controller';
|
||||
import CopyMoveHostsController from './copy-move-hosts.controller';
|
||||
import CopyMoveListController from './copy-move-list.controller';
|
||||
|
||||
var copyMoveGroupRoute = {
|
||||
name: 'inventoryManage.copyMoveGroup',
|
||||
@ -53,7 +54,8 @@ var copyMoveGroupRoute = {
|
||||
input_type: 'radio'
|
||||
});
|
||||
return html;
|
||||
}
|
||||
},
|
||||
controller: CopyMoveListController
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -90,7 +92,8 @@ var copyMoveHostRoute = {
|
||||
input_type: 'radio'
|
||||
});
|
||||
return html;
|
||||
}
|
||||
},
|
||||
controller: CopyMoveListController
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -258,11 +258,7 @@ export default ['$log', 'moment', function($log, moment){
|
||||
.split("\r\n");
|
||||
|
||||
if (lineNums.length > lines.length) {
|
||||
let padBy = lineNums.length - lines.length;
|
||||
|
||||
for (let i = 0; i <= padBy; i++) {
|
||||
lines.push("");
|
||||
}
|
||||
lineNums = lineNums.slice(0, lines.length);
|
||||
}
|
||||
|
||||
lines = this.distributeColors(lines);
|
||||
@ -293,7 +289,7 @@ export default ['$log', 'moment', function($log, moment){
|
||||
return `
|
||||
<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-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>`;
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
];
|
||||
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
];
|
||||
@ -42,6 +42,13 @@ export default
|
||||
else if(question.type === "multiplechoice") {
|
||||
question.model = question.default ? angular.copy(question.default) : "";
|
||||
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"){
|
||||
question.model = (!Empty(question.default)) ? angular.copy(question.default) : (!Empty(question.default_float)) ? angular.copy(question.default_float) : "";
|
||||
|
||||
@ -90,9 +90,9 @@ export default
|
||||
// for optional select lists, if they are left blank make sure we submit
|
||||
// a value that the API will consider "empty"
|
||||
//
|
||||
case "multiplechoice":
|
||||
job_launch_data.extra_vars[fld] = "";
|
||||
break;
|
||||
// ISSUE: I don't think this logic ever actually fires
|
||||
// When I tested this, we don't pass this extra var back
|
||||
// through the api when the mutliselect is optional and empty
|
||||
case "multiselect":
|
||||
job_launch_data.extra_vars[fld] = [];
|
||||
break;
|
||||
|
||||
@ -186,10 +186,11 @@
|
||||
<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>
|
||||
</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 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 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"/>
|
||||
|
||||
@ -16,6 +16,8 @@ import PromptForPasswords from './job-submission-factories/prompt-for-passwords.
|
||||
import submitJob from './job-submission.directive';
|
||||
import credentialList from './lists/credential/job-sub-cred-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
|
||||
angular.module('jobSubmission', [])
|
||||
@ -30,4 +32,6 @@ export default
|
||||
.factory('PromptForPasswords', PromptForPasswords)
|
||||
.directive('submitJob', submitJob)
|
||||
.directive('jobSubCredList', credentialList)
|
||||
.directive('jobSubInvList', inventoryList);
|
||||
.directive('jobSubInvList', inventoryList)
|
||||
.directive('awPasswordMin', awPasswordMin)
|
||||
.directive('awPasswordMax', awPasswordMax);
|
||||
|
||||
@ -1387,7 +1387,7 @@ function(ConfigurationUtils, i18n, $rootScope) {
|
||||
}
|
||||
}
|
||||
else {
|
||||
return false;
|
||||
return (!value || value === "") ? false : true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -254,6 +254,10 @@ export default ['$q', 'Rest', 'ProcessErrors', '$rootScope', 'Wait', 'DjangoSear
|
||||
.catch(function(response) {
|
||||
Wait('stop');
|
||||
|
||||
if (/^\/api\/v[0-9]+\/workflow_job_templates\/$/.test(endpoint) && response.status === 402) {
|
||||
return response;
|
||||
}
|
||||
|
||||
this.error(response.data, response.status);
|
||||
|
||||
throw response;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1351
awx/ui/po/es.po
1351
awx/ui/po/es.po
File diff suppressed because it is too large
Load Diff
@ -129,7 +129,7 @@ describe('parseStdoutService', () => {
|
||||
end_line: 11,
|
||||
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);
|
||||
|
||||
@ -204,7 +204,7 @@ describe('parseStdoutService', () => {
|
||||
var expectedString = `
|
||||
<div class="JobResultsStdOut-aLineOfStdOutline_classes">
|
||||
<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>`;
|
||||
expect(returnedString).toBe(expectedString);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user