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
commit e1e83598e9
50 changed files with 3787 additions and 2938 deletions

View File

@ -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 "#############################################"

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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

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)
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,

View File

@ -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

View File

@ -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):

View File

@ -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):
'''

View File

@ -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',):

View File

@ -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

View File

@ -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

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
@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"

View File

@ -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'))

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.
# 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

View File

@ -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")

View File

@ -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

View File

@ -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):

View File

@ -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')

View File

@ -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):

View File

@ -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

View File

@ -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,

View File

@ -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):

View File

@ -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;
}

View File

@ -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">

View File

@ -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',

View File

@ -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] === '') {

View File

@ -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')
});
}

View File

@ -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('^');

View File

@ -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('^');

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 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
}
}
};

View File

@ -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>`;
});

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") {
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) : "";

View File

@ -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;

View File

@ -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"/>

View File

@ -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);

View File

@ -1387,7 +1387,7 @@ function(ConfigurationUtils, i18n, $rootScope) {
}
}
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) {
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

File diff suppressed because it is too large Load Diff

View File

@ -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);
});