Merge remote-tracking branch 'origin/release_3.1.2' into devel

This commit is contained in:
Ryan Petrello
2017-03-21 10:39:16 -04:00
74 changed files with 872 additions and 258 deletions

View File

@@ -176,11 +176,11 @@ UI_RELEASE_FLAG_FILE = awx/ui/.release_built
.PHONY: clean clean-tmp clean-venv rebase push requirements requirements_dev \ .PHONY: clean clean-tmp clean-venv rebase push requirements requirements_dev \
develop refresh adduser migrate dbchange dbshell runserver celeryd \ develop refresh adduser migrate dbchange dbshell runserver celeryd \
receiver test test_unit test_coverage coverage_html test_jenkins dev_build \ receiver test test_unit test_ansible test_coverage coverage_html \
release_build release_clean sdist rpmtar mock-rpm mock-srpm rpm-sign \ test_jenkins dev_build release_build release_clean sdist rpmtar mock-rpm \
deb deb-src debian debsign pbuilder reprepro setup_tarball \ mock-srpm rpm-sign deb deb-src debian debsign pbuilder \
virtualbox-ovf virtualbox-centos-7 virtualbox-centos-6 \ reprepro setup_tarball virtualbox-ovf virtualbox-centos-7 \
clean-bundle setup_bundle_tarball \ virtualbox-centos-6 clean-bundle setup_bundle_tarball \
ui-docker-machine ui-docker ui-release ui-devel \ ui-docker-machine ui-docker ui-release ui-devel \
ui-test ui-deps ui-test-ci ui-test-saucelabs jlaska ui-test ui-deps ui-test-ci ui-test-saucelabs jlaska
@@ -264,8 +264,8 @@ virtualenv_ansible:
fi; \ fi; \
if [ ! -d "$(VENV_BASE)/ansible" ]; then \ if [ ! -d "$(VENV_BASE)/ansible" ]; then \
virtualenv --system-site-packages --setuptools $(VENV_BASE)/ansible && \ virtualenv --system-site-packages --setuptools $(VENV_BASE)/ansible && \
$(VENV_BASE)/ansible/bin/pip install -I setuptools==23.0.0 && \ $(VENV_BASE)/ansible/bin/pip install $(PIP_OPTIONS) --ignore-installed setuptools==23.0.0 && \
$(VENV_BASE)/ansible/bin/pip install -I pip==8.1.2; \ $(VENV_BASE)/ansible/bin/pip install $(PIP_OPTIONS) --ignore-installed pip==8.1.2; \
fi; \ fi; \
fi fi
@@ -276,42 +276,32 @@ virtualenv_tower:
fi; \ fi; \
if [ ! -d "$(VENV_BASE)/tower" ]; then \ if [ ! -d "$(VENV_BASE)/tower" ]; then \
virtualenv --system-site-packages --setuptools $(VENV_BASE)/tower && \ virtualenv --system-site-packages --setuptools $(VENV_BASE)/tower && \
$(VENV_BASE)/tower/bin/pip install -I setuptools==23.0.0 && \ $(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --ignore-installed setuptools==23.0.0 && \
$(VENV_BASE)/tower/bin/pip install -I pip==8.1.2; \ $(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --ignore-installed pip==8.1.2; \
fi; \ fi; \
fi fi
requirements_ansible: virtualenv_ansible requirements_ansible: virtualenv_ansible
$(VENV_BASE)/ansible/bin/pip install $(PIP_OPTIONS) --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements_ansible.txt
$(VENV_BASE)/ansible/bin/pip uninstall --yes -r requirements/requirements_ansible_uninstall.txt
requirements_ansible_dev:
if [ "$(VENV_BASE)" ]; then \ if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/ansible/bin/activate; \ $(VENV_BASE)/ansible/bin/pip install pytest; \
$(VENV_BASE)/ansible/bin/pip install --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements_ansible.txt ;\
$(VENV_BASE)/ansible/bin/pip uninstall --yes -r requirements/requirements_ansible_uninstall.txt; \
else \
pip install --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements_ansible.txt ; \
pip uninstall --yes -r requirements/requirements_ansible_uninstall.txt; \
fi fi
# Install third-party requirements needed for Tower's environment. # Install third-party requirements needed for Tower's environment.
requirements_tower: virtualenv_tower requirements_tower: virtualenv_tower
if [ "$(VENV_BASE)" ]; then \ $(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements.txt
. $(VENV_BASE)/tower/bin/activate; \ $(VENV_BASE)/tower/bin/pip uninstall --yes -r requirements/requirements_tower_uninstall.txt
$(VENV_BASE)/tower/bin/pip install --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements.txt ;\
$(VENV_BASE)/tower/bin/pip uninstall --yes -r requirements/requirements_tower_uninstall.txt; \
else \
pip install --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements.txt ; \
pip uninstall --yes -r requirements/requirements_tower_uninstall.txt; \
fi
requirements_tower_dev: requirements_tower_dev:
if [ "$(VENV_BASE)" ]; then \ $(VENV_BASE)/tower/bin/pip install -r requirements/requirements_dev.txt
. $(VENV_BASE)/tower/bin/activate; \ $(VENV_BASE)/tower/bin/pip uninstall --yes -r requirements/requirements_dev_uninstall.txt
$(VENV_BASE)/tower/bin/pip install -r requirements/requirements_dev.txt; \
$(VENV_BASE)/tower/bin/pip uninstall --yes -r requirements/requirements_dev_uninstall.txt; \
fi
requirements: requirements_ansible requirements_tower requirements: requirements_ansible requirements_tower
requirements_dev: requirements requirements_tower_dev requirements_dev: requirements requirements_tower_dev requirements_ansible_dev
requirements_test: requirements requirements_test: requirements
@@ -482,7 +472,7 @@ check: flake8 pep8 # pyflakes pylint
TEST_DIRS ?= awx/main/tests awx/conf/tests awx/sso/tests TEST_DIRS ?= awx/main/tests awx/conf/tests awx/sso/tests
# Run all API unit tests. # Run all API unit tests.
test: test: test_ansible
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \ . $(VENV_BASE)/tower/bin/activate; \
fi; \ fi; \
@@ -494,6 +484,12 @@ test_unit:
fi; \ fi; \
py.test awx/main/tests/unit awx/conf/tests/unit awx/sso/tests/unit py.test awx/main/tests/unit awx/conf/tests/unit awx/sso/tests/unit
test_ansible:
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/ansible/bin/activate; \
fi; \
py.test awx/lib/tests -c awx/lib/tests/pytest.ini
# Run all API unit tests with coverage enabled. # Run all API unit tests with coverage enabled.
test_coverage: test_coverage:
@if [ "$(VENV_BASE)" ]; then \ @if [ "$(VENV_BASE)" ]; then \
@@ -712,6 +708,30 @@ rpm-build/$(SDIST_TAR_FILE): rpm-build dist/$(SDIST_TAR_FILE)
rpmtar: sdist rpm-build/$(SDIST_TAR_FILE) rpmtar: sdist rpm-build/$(SDIST_TAR_FILE)
brewrpmtar: rpm-build/python-deps.tar.gz rpmtar
rpm-build/python-deps.tar.gz: requirements/vendor rpm-build
tar czf rpm-build/python-deps.tar.gz requirements/vendor
requirements/vendor:
pip download \
--no-binary=:all: \
--requirement=requirements/requirements_ansible.txt \
--dest=$@ \
--exists-action=i
pip download \
--no-binary=:all: \
--requirement=requirements/requirements.txt \
--dest=$@ \
--exists-action=i
pip download \
--no-binary=:all: \
--requirement=requirements/requirements_setup_requires.txt \
--dest=$@ \
--exists-action=i
rpm-build/$(RPM_NVR).src.rpm: /etc/mock/$(MOCK_CFG).cfg rpm-build/$(RPM_NVR).src.rpm: /etc/mock/$(MOCK_CFG).cfg
$(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --buildsrpm --spec rpm-build/$(NAME).spec --sources rpm-build \ $(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --buildsrpm --spec rpm-build/$(NAME).spec --sources rpm-build \
--define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES) --define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES)
@@ -722,6 +742,8 @@ mock-srpm: rpmtar rpm-build/$(RPM_NVR).src.rpm
@echo rpm-build/$(RPM_NVR).src.rpm @echo rpm-build/$(RPM_NVR).src.rpm
@echo "#############################################" @echo "#############################################"
brew-srpm: brewrpmtar mock-srpm
rpm-build/$(RPM_NVR).$(RPM_ARCH).rpm: rpm-build/$(RPM_NVR).src.rpm rpm-build/$(RPM_NVR).$(RPM_ARCH).rpm: rpm-build/$(RPM_NVR).src.rpm
$(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --rebuild rpm-build/$(RPM_NVR).src.rpm \ $(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --rebuild rpm-build/$(RPM_NVR).src.rpm \
--define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES) --define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES)

View File

@@ -316,6 +316,8 @@ class OrderByBackend(BaseFilterBackend):
else: else:
order_by = (value,) order_by = (value,)
if order_by: if order_by:
order_by = self._strip_sensitive_model_fields(queryset.model, order_by)
# Special handling of the type field for ordering. In this # Special handling of the type field for ordering. In this
# case, we're not sorting exactly on the type field, but # case, we're not sorting exactly on the type field, but
# given the limited number of views with multiple types, # given the limited number of views with multiple types,
@@ -338,3 +340,16 @@ class OrderByBackend(BaseFilterBackend):
except FieldError as e: except FieldError as e:
# Return a 400 for invalid field names. # Return a 400 for invalid field names.
raise ParseError(*e.args) raise ParseError(*e.args)
def _strip_sensitive_model_fields(self, model, order_by):
for field_name in order_by:
# strip off the negation prefix `-` if it exists
_field_name = field_name.split('-')[-1]
try:
# if the field name is encrypted/sensitive, don't sort on it
if _field_name in getattr(model, 'PASSWORD_FIELDS', ()) or \
getattr(model._meta.get_field(_field_name), '__prevent_search__', False):
raise ParseError(_('cannot order by field %s') % _field_name)
except FieldDoesNotExist:
pass
yield field_name

View File

@@ -2678,7 +2678,8 @@ class JobTemplateCallback(GenericAPIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
extra_vars = None extra_vars = None
if request.content_type == "application/json": # Be careful here: content_type can look like '<content_type>; charset=blar'
if request.content_type.startswith("application/json"):
extra_vars = request.data.get("extra_vars", None) extra_vars = request.data.get("extra_vars", None)
# Permission class should have already validated host_config_key. # Permission class should have already validated host_config_key.
job_template = self.get_object() job_template = self.get_object()
@@ -2727,14 +2728,14 @@ class JobTemplateCallback(GenericAPIView):
return Response(data, status=status.HTTP_400_BAD_REQUEST) return Response(data, status=status.HTTP_400_BAD_REQUEST)
# Everything is fine; actually create the job. # Everything is fine; actually create the job.
kv = {"limit": limit, "launch_type": 'callback'}
if extra_vars is not None and job_template.ask_variables_on_launch:
kv['extra_vars'] = callback_filter_out_ansible_extra_vars(extra_vars)
with transaction.atomic(): with transaction.atomic():
job = job_template.create_job(limit=limit, launch_type='callback') job = job_template.create_job(**kv)
# Send a signal to celery that the job should be started. # Send a signal to celery that the job should be started.
kv = {"inventory_sources_already_updated": inventory_sources_already_updated} result = job.signal_start(inventory_sources_already_updated=inventory_sources_already_updated)
if extra_vars is not None:
kv['extra_vars'] = callback_filter_out_ansible_extra_vars(extra_vars)
result = job.signal_start(**kv)
if not result: if not result:
data = dict(msg=_('Error starting job!')) data = dict(msg=_('Error starting job!'))
return Response(data, status=status.HTTP_400_BAD_REQUEST) return Response(data, status=status.HTTP_400_BAD_REQUEST)
@@ -3665,7 +3666,7 @@ class AdHocCommandRelaunch(GenericAPIView):
data = {} data = {}
for field in ('job_type', 'inventory_id', 'limit', 'credential_id', for field in ('job_type', 'inventory_id', 'limit', 'credential_id',
'module_name', 'module_args', 'forks', 'verbosity', 'module_name', 'module_args', 'forks', 'verbosity',
'become_enabled'): 'extra_vars', 'become_enabled'):
if field.endswith('_id'): if field.endswith('_id'):
data[field[:-3]] = getattr(obj, field) data[field[:-3]] = getattr(obj, field)
else: else:

View File

2
awx/lib/tests/pytest.ini Normal file
View File

@@ -0,0 +1,2 @@
[pytest]
addopts = -v

View File

@@ -0,0 +1,213 @@
from collections import OrderedDict
import json
import mock
import os
import sys
import pytest
# ansible uses `ANSIBLE_CALLBACK_PLUGINS` and `ANSIBLE_STDOUT_CALLBACK` to
# discover callback plugins; `ANSIBLE_CALLBACK_PLUGINS` is a list of paths to
# search for a plugin implementation (which should be named `CallbackModule`)
#
# this code modifies the Python path to make our
# `awx.lib.tower_display_callback` callback importable (because `awx.lib`
# itself is not a package)
#
# we use the `tower_display_callback` imports below within this file, but
# Ansible also uses them when it discovers this file in
# `ANSIBLE_CALLBACK_PLUGINS`
CALLBACK = os.path.splitext(os.path.basename(__file__))[0]
PLUGINS = os.path.dirname(__file__)
with mock.patch.dict(os.environ, {'ANSIBLE_STDOUT_CALLBACK': CALLBACK,
'ANSIBLE_CALLBACK_PLUGINS': PLUGINS}):
from ansible.cli.playbook import PlaybookCLI
from ansible.executor.playbook_executor import PlaybookExecutor
from ansible.inventory import Inventory
from ansible.parsing.dataloader import DataLoader
from ansible.vars import VariableManager
# Add awx/lib to sys.path so we can use the plugin
path = os.path.abspath(os.path.join(PLUGINS, '..', '..'))
if path not in sys.path:
sys.path.insert(0, path)
from tower_display_callback import TowerDefaultCallbackModule as CallbackModule # noqa
from tower_display_callback.events import event_context # noqa
@pytest.fixture()
def cache(request):
class Cache(OrderedDict):
def set(self, key, value):
self[key] = value
local_cache = Cache()
patch = mock.patch.object(event_context, 'cache', local_cache)
patch.start()
request.addfinalizer(patch.stop)
return local_cache
@pytest.fixture()
def executor(tmpdir_factory, request):
playbooks = request.node.callspec.params.get('playbook')
playbook_files = []
for name, playbook in playbooks.items():
filename = str(tmpdir_factory.mktemp('data').join(name))
with open(filename, 'w') as f:
f.write(playbook)
playbook_files.append(filename)
cli = PlaybookCLI(['', 'playbook.yml'])
cli.parse()
options = cli.parser.parse_args([])[0]
loader = DataLoader()
variable_manager = VariableManager()
inventory = Inventory(loader=loader, variable_manager=variable_manager,
host_list=['localhost'])
variable_manager.set_inventory(inventory)
return PlaybookExecutor(playbooks=playbook_files, inventory=inventory,
variable_manager=variable_manager, loader=loader,
options=options, passwords={})
@pytest.mark.parametrize('event', {'playbook_on_start',
'playbook_on_play_start',
'playbook_on_task_start', 'runner_on_ok',
'playbook_on_stats'})
@pytest.mark.parametrize('playbook', [
{'helloworld.yml': '''
- name: Hello World Sample
connection: local
hosts: all
gather_facts: no
tasks:
- name: Hello Message
debug:
msg: "Hello World!"
'''} # noqa
])
def test_callback_plugin_receives_events(executor, cache, event, playbook):
executor.run()
assert len(cache)
assert event in [task['event'] for task in cache.values()]
@pytest.mark.parametrize('playbook', [
{'no_log_on_ok.yml': '''
- name: args should not be logged when task-level no_log is set
connection: local
hosts: all
gather_facts: no
tasks:
- shell: echo "SENSITIVE"
no_log: true
'''}, # noqa
{'no_log_on_fail.yml': '''
- name: failed args should not be logged when task-level no_log is set
connection: local
hosts: all
gather_facts: no
tasks:
- shell: echo "SENSITIVE"
no_log: true
failed_when: true
ignore_errors: true
'''}, # noqa
{'no_log_on_skip.yml': '''
- name: skipped task args should be suppressed with no_log
connection: local
hosts: all
gather_facts: no
tasks:
- shell: echo "SENSITIVE"
no_log: true
when: false
'''}, # noqa
{'no_log_on_play.yml': '''
- name: args should not be logged when play-level no_log set
connection: local
hosts: all
gather_facts: no
no_log: true
tasks:
- shell: echo "SENSITIVE"
'''}, # noqa
{'async_no_log.yml': '''
- name: async task args should suppressed with no_log
connection: local
hosts: all
gather_facts: no
no_log: true
tasks:
- async: 10
poll: 1
shell: echo "SENSITIVE"
no_log: true
'''}, # noqa
{'with_items.yml': '''
- name: with_items tasks should be suppressed with no_log
connection: local
hosts: all
gather_facts: no
tasks:
- shell: echo {{ item }}
no_log: true
with_items: [ "SENSITIVE", "SENSITIVE-SKIPPED", "SENSITIVE-FAILED" ]
when: item != "SENSITIVE-SKIPPED"
failed_when: item == "SENSITIVE-FAILED"
ignore_errors: yes
'''}, # noqa
])
def test_callback_plugin_no_log_filters(executor, cache, playbook):
executor.run()
assert len(cache)
assert 'SENSITIVE' not in json.dumps(cache.items())
@pytest.mark.parametrize('playbook', [
{'no_log_on_ok.yml': '''
- name: args should not be logged when task-level no_log is set
connection: local
hosts: all
gather_facts: no
tasks:
- shell: echo "SENSITIVE"
- shell: echo "PRIVATE"
no_log: true
'''}, # noqa
])
def test_callback_plugin_task_args_leak(executor, cache, playbook):
executor.run()
events = cache.values()
assert events[0]['event'] == 'playbook_on_start'
assert events[1]['event'] == 'playbook_on_play_start'
# task 1
assert events[2]['event'] == 'playbook_on_task_start'
assert 'SENSITIVE' in events[2]['event_data']['task_args']
assert events[3]['event'] == 'runner_on_ok'
assert 'SENSITIVE' in events[3]['event_data']['task_args']
# task 2 no_log=True
assert events[4]['event'] == 'playbook_on_task_start'
assert events[4]['event_data']['task_args'] == "the output has been hidden due to the fact that 'no_log: true' was specified for this result" # noqa
assert events[5]['event'] == 'runner_on_ok'
assert events[5]['event_data']['task_args'] == "the output has been hidden due to the fact that 'no_log: true' was specified for this result" # noqa
@pytest.mark.parametrize('playbook', [
{'strip_env_vars.yml': '''
- name: sensitive environment variables should be stripped from events
connection: local
hosts: all
tasks:
- shell: echo "Hello, World!"
'''}, # noqa
])
def test_callback_plugin_strips_task_environ_variables(executor, cache, playbook):
executor.run()
assert len(cache)
for event in cache.values():
assert os.environ['PATH'] not in json.dumps(event)

View File

@@ -55,22 +55,6 @@ class BaseCallbackModule(CallbackBase):
'playbook_on_no_hosts_remaining', 'playbook_on_no_hosts_remaining',
] ]
CENSOR_FIELD_WHITELIST = [
'msg',
'failed',
'changed',
'results',
'start',
'end',
'delta',
'cmd',
'_ansible_no_log',
'rc',
'failed_when_result',
'skipped',
'skip_reason',
]
def __init__(self): def __init__(self):
super(BaseCallbackModule, self).__init__() super(BaseCallbackModule, self).__init__()
self.task_uuids = set() self.task_uuids = set()
@@ -85,6 +69,9 @@ class BaseCallbackModule(CallbackBase):
else: else:
task = None task = None
if event_data.get('res') and event_data['res'].get('_ansible_no_log', False):
event_data['res'] = {'censored': "the output has been hidden due to the fact that 'no_log: true' was specified for this result"} # noqa
with event_context.display_lock: with event_context.display_lock:
try: try:
event_context.add_local(event=event, **event_data) event_context.add_local(event=event, **event_data)
@@ -132,7 +119,9 @@ class BaseCallbackModule(CallbackBase):
task_ctx['task_path'] = task.get_path() task_ctx['task_path'] = task.get_path()
except AttributeError: except AttributeError:
pass pass
if not task.no_log: if task.no_log:
task_ctx['task_args'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
else:
task_args = ', '.join(('%s=%s' % a for a in task.args.items())) task_args = ', '.join(('%s=%s' % a for a in task.args.items()))
task_ctx['task_args'] = task_args task_ctx['task_args'] = task_args
if getattr(task, '_role', None): if getattr(task, '_role', None):

View File

@@ -190,7 +190,7 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin):
data = {} data = {}
for field in ('job_type', 'inventory_id', 'limit', 'credential_id', for field in ('job_type', 'inventory_id', 'limit', 'credential_id',
'module_name', 'module_args', 'forks', 'verbosity', 'module_name', 'module_args', 'forks', 'verbosity',
'become_enabled'): 'extra_vars', 'become_enabled'):
data[field] = getattr(self, field) data[field] = getattr(self, field)
return AdHocCommand.objects.create(**data) return AdHocCommand.objects.create(**data)

View File

@@ -1277,10 +1277,20 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin):
def get_notification_friendly_name(self): def get_notification_friendly_name(self):
return "Inventory Update" return "Inventory Update"
def cancel(self): def _build_job_explanation(self):
res = super(InventoryUpdate, self).cancel() if not self.job_explanation:
return 'Previous Task Canceled: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % \
(self.model_to_str(), self.name, self.id)
return None
def get_dependent_jobs(self):
return Job.objects.filter(dependent_jobs__in=[self.id])
def cancel(self, job_explanation=None):
res = super(InventoryUpdate, self).cancel(job_explanation=job_explanation)
if res: if res:
map(lambda x: x.cancel(), Job.objects.filter(dependent_jobs__in=[self.id])) map(lambda x: x.cancel(job_explanation=self._build_job_explanation()), self.get_dependent_jobs())
return res return res

View File

@@ -310,9 +310,13 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
elif self.variables_needed_to_start: elif self.variables_needed_to_start:
variables_needed = True variables_needed = True
prompting_needed = False prompting_needed = False
for value in self._ask_for_vars_dict().values(): # The behavior of provisioning callback should mimic
if value: # that of job template launch, so prompting_needed should
prompting_needed = True # not block a provisioning callback from creating/launching jobs.
if callback_extra_vars is None:
for value in self._ask_for_vars_dict().values():
if value:
prompting_needed = True
return (not prompting_needed and return (not prompting_needed and
not self.passwords_needed_to_start and not self.passwords_needed_to_start and
not variables_needed) not variables_needed)
@@ -633,10 +637,10 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin):
Canceling a job also cancels the implicit project update with launch_type Canceling a job also cancels the implicit project update with launch_type
run. run.
''' '''
def cancel(self): def cancel(self, job_explanation=None):
res = super(Job, self).cancel() res = super(Job, self).cancel(job_explanation=job_explanation)
if self.project_update: if self.project_update:
self.project_update.cancel() self.project_update.cancel(job_explanation=job_explanation)
return res return res

View File

@@ -1025,7 +1025,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
if settings.DEBUG: if settings.DEBUG:
raise raise
def cancel(self): def cancel(self, job_explanation=None):
if self.can_cancel: if self.can_cancel:
if not self.cancel_flag: if not self.cancel_flag:
self.cancel_flag = True self.cancel_flag = True
@@ -1033,6 +1033,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
if self.status in ('pending', 'waiting', 'new'): if self.status in ('pending', 'waiting', 'new'):
self.status = 'canceled' self.status = 'canceled'
cancel_fields.append('status') cancel_fields.append('status')
if job_explanation is not None:
self.job_explanation = job_explanation
cancel_fields.append('job_explanation')
self.save(update_fields=cancel_fields) self.save(update_fields=cancel_fields)
self.websocket_emit_status("canceled") self.websocket_emit_status("canceled")
if settings.BROKER_URL.startswith('amqp://'): if settings.BROKER_URL.startswith('amqp://'):

View File

@@ -90,7 +90,7 @@ def celery_startup(conf=None, **kwargs):
@worker_process_init.connect @worker_process_init.connect
def task_set_logger_pre_run(*args, **kwargs): def task_set_logger_pre_run(*args, **kwargs):
cache.close() cache.close()
configure_external_logger(settings, async_flag=False, is_startup=False) configure_external_logger(settings, is_startup=False)
def _clear_cache_keys(set_of_keys): def _clear_cache_keys(set_of_keys):
@@ -1160,6 +1160,7 @@ class RunProjectUpdate(BaseTask):
''' '''
env = super(RunProjectUpdate, self).build_env(project_update, **kwargs) env = super(RunProjectUpdate, self).build_env(project_update, **kwargs)
env = self.add_ansible_venv(env) env = self.add_ansible_venv(env)
env['ANSIBLE_RETRY_FILES_ENABLED'] = str(False)
env['ANSIBLE_ASK_PASS'] = str(False) env['ANSIBLE_ASK_PASS'] = str(False)
env['ANSIBLE_ASK_SUDO_PASS'] = str(False) env['ANSIBLE_ASK_SUDO_PASS'] = str(False)
env['DISPLAY'] = '' # Prevent stupid password popup when running tests. env['DISPLAY'] = '' # Prevent stupid password popup when running tests.

View File

@@ -339,6 +339,21 @@ def test_list_created_org_credentials(post, get, organization, org_admin, org_me
assert response.data['count'] == 0 assert response.data['count'] == 0
@pytest.mark.parametrize('order_by', ('password', '-password', 'password,pk', '-password,pk'))
@pytest.mark.django_db
def test_list_cannot_order_by_encrypted_field(post, get, organization, org_admin, order_by):
for i, password in enumerate(('abc', 'def', 'xyz')):
response = post(reverse('api:credential_list'), {
'organization': organization.id,
'name': 'C%d' % i,
'password': password
}, org_admin)
response = get(reverse('api:credential_list'), org_admin,
QUERY_STRING='order_by=%s' % order_by, status=400)
assert response.status_code == 400
# #
# Openstack Credentials # Openstack Credentials
# #

View File

@@ -35,6 +35,21 @@ def test_edit_inventory(put, inventory, alice, role_field, expected_status_code)
put(reverse('api:inventory_detail', args=(inventory.id,)), data, alice, expect=expected_status_code) put(reverse('api:inventory_detail', args=(inventory.id,)), data, alice, expect=expected_status_code)
@pytest.mark.parametrize('order_by', ('script', '-script', 'script,pk', '-script,pk'))
@pytest.mark.django_db
def test_list_cannot_order_by_unsearchable_field(get, organization, alice, order_by):
for i, script in enumerate(('#!/bin/a', '#!/bin/b', '#!/bin/c')):
custom_script = organization.custom_inventory_scripts.create(
name="I%d" % i,
script=script
)
custom_script.admin_role.members.add(alice)
response = get(reverse('api:inventory_script_list'), alice,
QUERY_STRING='order_by=%s' % order_by, status=400)
assert response.status_code == 400
@pytest.mark.parametrize("role_field,expected_status_code", [ @pytest.mark.parametrize("role_field,expected_status_code", [
(None, 403), (None, 403),
('admin_role', 201), ('admin_role', 201),

View File

@@ -344,3 +344,53 @@ def test_job_launch_unprompted_vars_with_survey(mocker, survey_spec_factory, job
# Check that the survey variable is accepted and the job variable isn't # Check that the survey variable is accepted and the job variable isn't
mock_job.signal_start.assert_called_once() mock_job.signal_start.assert_called_once()
@pytest.mark.django_db
@pytest.mark.job_runtime_vars
def test_callback_accept_prompted_extra_var(mocker, survey_spec_factory, job_template_prompts, post, admin_user, host):
job_template = job_template_prompts(True)
job_template.host_config_key = "foo"
job_template.survey_enabled = True
job_template.survey_spec = survey_spec_factory('survey_var')
job_template.save()
with mocker.patch('awx.main.access.BaseAccess.check_license'):
mock_job = mocker.MagicMock(spec=Job, id=968, extra_vars={"job_launch_var": 3, "survey_var": 4})
with mocker.patch.object(JobTemplate, 'create_unified_job', return_value=mock_job):
with mocker.patch('awx.api.serializers.JobSerializer.to_representation', return_value={}):
with mocker.patch('awx.api.views.JobTemplateCallback.find_matching_hosts', return_value=[host]):
post(
reverse('api:job_template_callback', args=[job_template.pk]),
dict(extra_vars={"job_launch_var": 3, "survey_var": 4}, host_config_key="foo"),
admin_user, expect=201, format='json')
assert JobTemplate.create_unified_job.called
assert JobTemplate.create_unified_job.call_args == ({'extra_vars': {'survey_var': 4,
'job_launch_var': 3},
'launch_type': 'callback',
'limit': 'single-host'},)
mock_job.signal_start.assert_called_once()
@pytest.mark.django_db
@pytest.mark.job_runtime_vars
def test_callback_ignore_unprompted_extra_var(mocker, survey_spec_factory, job_template_prompts, post, admin_user, host):
job_template = job_template_prompts(False)
job_template.host_config_key = "foo"
job_template.save()
with mocker.patch('awx.main.access.BaseAccess.check_license'):
mock_job = mocker.MagicMock(spec=Job, id=968, extra_vars={"job_launch_var": 3, "survey_var": 4})
with mocker.patch.object(JobTemplate, 'create_unified_job', return_value=mock_job):
with mocker.patch('awx.api.serializers.JobSerializer.to_representation', return_value={}):
with mocker.patch('awx.api.views.JobTemplateCallback.find_matching_hosts', return_value=[host]):
post(
reverse('api:job_template_callback', args=[job_template.pk]),
dict(extra_vars={"job_launch_var": 3, "survey_var": 4}, host_config_key="foo"),
admin_user, expect=201, format='json')
assert JobTemplate.create_unified_job.called
assert JobTemplate.create_unified_job.call_args == ({'launch_type': 'callback',
'limit': 'single-host'},)
mock_job.signal_start.assert_called_once()

View File

@@ -92,3 +92,15 @@ def test_expired_licenses():
assert vdata['compliant'] is False assert vdata['compliant'] is False
assert vdata['grace_period_remaining'] > 0 assert vdata['grace_period_remaining'] > 0
@pytest.mark.django_db
def test_cloudforms_license(mocker):
with mocker.patch('awx.main.task_engine.TaskEnhancer._check_cloudforms_subscription', return_value=True):
task_enhancer = TaskEnhancer()
vdata = task_enhancer.validate_enhancements()
assert vdata['compliant'] is True
assert vdata['subscription_name'] == "Red Hat CloudForms License"
assert vdata['available_instances'] == 9999999
assert vdata['license_type'] == 'enterprise'
assert vdata['features']['ha'] is True

View File

View File

@@ -0,0 +1,38 @@
import pytest
import mock
from awx.main.models import (
UnifiedJob,
InventoryUpdate,
Job,
)
@pytest.fixture
def dependent_job(mocker):
j = Job(id=3, name='I_am_a_job')
j.cancel = mocker.MagicMock(return_value=True)
return [j]
def test_cancel(mocker, dependent_job):
with mock.patch.object(UnifiedJob, 'cancel', return_value=True) as parent_cancel:
iu = InventoryUpdate()
iu.get_dependent_jobs = mocker.MagicMock(return_value=dependent_job)
iu.save = mocker.MagicMock()
build_job_explanation_mock = mocker.MagicMock()
iu._build_job_explanation = mocker.MagicMock(return_value=build_job_explanation_mock)
iu.cancel()
parent_cancel.assert_called_with(job_explanation=None)
dependent_job[0].cancel.assert_called_with(job_explanation=build_job_explanation_mock)
def test__build_job_explanation():
iu = InventoryUpdate(id=3, name='I_am_an_Inventory_Update')
job_explanation = iu._build_job_explanation()
assert job_explanation == 'Previous Task Canceled: {"job_type": "%s", "job_name": "%s", "job_id": "%s"}' % \
('inventory_update', 'I_am_an_Inventory_Update', 3)

View File

@@ -115,3 +115,16 @@ def test_job_template_survey_mixin_length(job_template_factory):
{'type':'password', 'variable':'my_other_variable'}]} {'type':'password', 'variable':'my_other_variable'}]}
kwargs = obj._update_unified_job_kwargs(extra_vars={'my_variable':'$encrypted$'}) kwargs = obj._update_unified_job_kwargs(extra_vars={'my_variable':'$encrypted$'})
assert kwargs['extra_vars'] == '{"my_variable": "my_default"}' assert kwargs['extra_vars'] == '{"my_variable": "my_default"}'
def test_job_template_can_start_with_callback_extra_vars_provided(job_template_factory):
objects = job_template_factory(
'callback_extra_vars_test',
organization='org1',
inventory='inventory1',
credential='cred1',
persisted=False,
)
obj = objects.job_template
obj.ask_variables_on_launch = True
assert obj.can_start_without_user_input(callback_extra_vars='{"foo": "bar"}') is True

View File

@@ -1,3 +1,4 @@
import pytest
import mock import mock
from awx.main.models import ( from awx.main.models import (
@@ -14,3 +15,38 @@ def test_unified_job_workflow_attributes():
assert job.spawned_by_workflow is True assert job.spawned_by_workflow is True
assert job.workflow_job_id == 1 assert job.workflow_job_id == 1
@pytest.fixture
def unified_job(mocker):
mocker.patch.object(UnifiedJob, 'can_cancel', return_value=True)
j = UnifiedJob()
j.status = 'pending'
j.cancel_flag = None
j.save = mocker.MagicMock()
j.websocket_emit_status = mocker.MagicMock()
return j
def test_cancel(unified_job):
unified_job.cancel()
assert unified_job.cancel_flag is True
assert unified_job.status == 'canceled'
assert unified_job.job_explanation == ''
# Note: the websocket emit status check is just reflecting the state of the current code.
# Some more thought may want to go into only emitting canceled if/when the job record
# status is changed to canceled. Unlike, currently, where it's emitted unconditionally.
unified_job.websocket_emit_status.assert_called_with("canceled")
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'status'])
def test_cancel_job_explanation(unified_job):
job_explanation = 'giggity giggity'
unified_job.cancel(job_explanation=job_explanation)
assert unified_job.job_explanation == job_explanation
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'status', 'job_explanation'])

View File

@@ -1,8 +1,10 @@
import base64 import base64
import cStringIO
import json import json
import logging import logging
from uuid import uuid4 from uuid import uuid4
from django.conf import settings
from django.conf import LazySettings from django.conf import LazySettings
import pytest import pytest
import requests import requests
@@ -44,17 +46,27 @@ def http_adapter():
return FakeHTTPAdapter() return FakeHTTPAdapter()
def test_https_logging_handler_requests_sync_implementation(): @pytest.fixture()
handler = HTTPSHandler(async=False) def connection_error_adapter():
assert not isinstance(handler.session, FuturesSession) class ConnectionErrorAdapter(requests.adapters.HTTPAdapter):
assert isinstance(handler.session, requests.Session)
def send(self, request, **kwargs):
err = requests.packages.urllib3.exceptions.SSLError()
raise requests.exceptions.ConnectionError(err, request=request)
return ConnectionErrorAdapter()
def test_https_logging_handler_requests_async_implementation(): def test_https_logging_handler_requests_async_implementation():
handler = HTTPSHandler(async=True) handler = HTTPSHandler()
assert isinstance(handler.session, FuturesSession) assert isinstance(handler.session, FuturesSession)
def test_https_logging_handler_has_default_http_timeout():
handler = HTTPSHandler.from_django_settings(settings)
assert handler.http_timeout == 5
@pytest.mark.parametrize('param', PARAM_NAMES.keys()) @pytest.mark.parametrize('param', PARAM_NAMES.keys())
def test_https_logging_handler_defaults(param): def test_https_logging_handler_defaults(param):
handler = HTTPSHandler() handler = HTTPSHandler()
@@ -154,18 +166,39 @@ def test_https_logging_handler_skip_log(params, logger_name, expected):
assert handler.skip_log(logger_name) is expected assert handler.skip_log(logger_name) is expected
@pytest.mark.parametrize('message_type, async', [ def test_https_logging_handler_connection_error(connection_error_adapter,
('logstash', False), dummy_log_record):
('logstash', True), handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True,
('splunk', False), message_type='logstash',
('splunk', True), enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'])
]) handler.setFormatter(LogstashFormatter())
handler.session.mount('http://', connection_error_adapter)
buff = cStringIO.StringIO()
logging.getLogger('awx.main.utils.handlers').addHandler(
logging.StreamHandler(buff)
)
async_futures = handler.emit(dummy_log_record)
with pytest.raises(requests.exceptions.ConnectionError):
[future.result() for future in async_futures]
assert 'failed to emit log to external aggregator\nTraceback' in buff.getvalue()
# we should only log failures *periodically*, so causing *another*
# immediate failure shouldn't report a second ConnectionError
buff.truncate(0)
async_futures = handler.emit(dummy_log_record)
with pytest.raises(requests.exceptions.ConnectionError):
[future.result() for future in async_futures]
assert buff.getvalue() == ''
@pytest.mark.parametrize('message_type', ['logstash', 'splunk'])
def test_https_logging_handler_emit(http_adapter, dummy_log_record, def test_https_logging_handler_emit(http_adapter, dummy_log_record,
message_type, async): message_type):
handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True, handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True,
message_type=message_type, message_type=message_type,
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'], enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'])
async=async)
handler.setFormatter(LogstashFormatter()) handler.setFormatter(LogstashFormatter())
handler.session.mount('http://', http_adapter) handler.session.mount('http://', http_adapter)
async_futures = handler.emit(dummy_log_record) async_futures = handler.emit(dummy_log_record)
@@ -191,14 +224,12 @@ def test_https_logging_handler_emit(http_adapter, dummy_log_record,
assert body['message'] == 'User joe logged in' assert body['message'] == 'User joe logged in'
@pytest.mark.parametrize('async', (True, False))
def test_https_logging_handler_emit_logstash_with_creds(http_adapter, def test_https_logging_handler_emit_logstash_with_creds(http_adapter,
dummy_log_record, async): dummy_log_record):
handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True, handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True,
username='user', password='pass', username='user', password='pass',
message_type='logstash', message_type='logstash',
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'], enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'])
async=async)
handler.setFormatter(LogstashFormatter()) handler.setFormatter(LogstashFormatter())
handler.session.mount('http://', http_adapter) handler.session.mount('http://', http_adapter)
async_futures = handler.emit(dummy_log_record) async_futures = handler.emit(dummy_log_record)
@@ -209,13 +240,11 @@ def test_https_logging_handler_emit_logstash_with_creds(http_adapter,
assert request.headers['Authorization'] == 'Basic %s' % base64.b64encode("user:pass") assert request.headers['Authorization'] == 'Basic %s' % base64.b64encode("user:pass")
@pytest.mark.parametrize('async', (True, False))
def test_https_logging_handler_emit_splunk_with_creds(http_adapter, def test_https_logging_handler_emit_splunk_with_creds(http_adapter,
dummy_log_record, async): dummy_log_record):
handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True, handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True,
password='pass', message_type='splunk', password='pass', message_type='splunk',
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'], enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'])
async=async)
handler.setFormatter(LogstashFormatter()) handler.setFormatter(LogstashFormatter())
handler.session.mount('http://', http_adapter) handler.session.mount('http://', http_adapter)
async_futures = handler.emit(dummy_log_record) async_futures = handler.emit(dummy_log_record)

View File

@@ -5,8 +5,10 @@
import logging import logging
import json import json
import requests import requests
from requests.exceptions import RequestException import time
from concurrent.futures import ThreadPoolExecutor
from copy import copy from copy import copy
from requests.exceptions import RequestException
# loggly # loggly
import traceback import traceback
@@ -19,6 +21,8 @@ from awx.main.utils.formatters import LogstashFormatter
__all__ = ['HTTPSNullHandler', 'BaseHTTPSHandler', 'configure_external_logger'] __all__ = ['HTTPSNullHandler', 'BaseHTTPSHandler', 'configure_external_logger']
logger = logging.getLogger('awx.main.utils.handlers')
# AWX external logging handler, generally designed to be used # AWX external logging handler, generally designed to be used
# with the accompanying LogstashHandler, derives from python-logstash library # with the accompanying LogstashHandler, derives from python-logstash library
# Non-blocking request accomplished by FuturesSession, similar # Non-blocking request accomplished by FuturesSession, similar
@@ -34,6 +38,7 @@ PARAM_NAMES = {
'enabled_loggers': 'LOG_AGGREGATOR_LOGGERS', 'enabled_loggers': 'LOG_AGGREGATOR_LOGGERS',
'indv_facts': 'LOG_AGGREGATOR_INDIVIDUAL_FACTS', 'indv_facts': 'LOG_AGGREGATOR_INDIVIDUAL_FACTS',
'enabled_flag': 'LOG_AGGREGATOR_ENABLED', 'enabled_flag': 'LOG_AGGREGATOR_ENABLED',
'http_timeout': 'LOG_AGGREGATOR_HTTP_TIMEOUT',
} }
@@ -52,17 +57,41 @@ class HTTPSNullHandler(logging.NullHandler):
return super(HTTPSNullHandler, self).__init__() return super(HTTPSNullHandler, self).__init__()
class VerboseThreadPoolExecutor(ThreadPoolExecutor):
last_log_emit = 0
def submit(self, func, *args, **kwargs):
def _wrapped(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception:
# If an exception occurs in a concurrent thread worker (like
# a ConnectionError or a read timeout), periodically log
# that failure.
#
# This approach isn't really thread-safe, so we could
# potentially log once per thread every 10 seconds, but it
# beats logging *every* failed HTTP request in a scenario where
# you've typo'd your log aggregator hostname.
now = time.time()
if now - self.last_log_emit > 10:
logger.exception('failed to emit log to external aggregator')
self.last_log_emit = now
raise
return super(VerboseThreadPoolExecutor, self).submit(_wrapped, *args,
**kwargs)
class BaseHTTPSHandler(logging.Handler): class BaseHTTPSHandler(logging.Handler):
def __init__(self, fqdn=False, **kwargs): def __init__(self, fqdn=False, **kwargs):
super(BaseHTTPSHandler, self).__init__() super(BaseHTTPSHandler, self).__init__()
self.fqdn = fqdn self.fqdn = fqdn
self.async = kwargs.get('async', True)
for fd in PARAM_NAMES: for fd in PARAM_NAMES:
setattr(self, fd, kwargs.get(fd, None)) setattr(self, fd, kwargs.get(fd, None))
if self.async: self.session = FuturesSession(executor=VerboseThreadPoolExecutor(
self.session = FuturesSession() max_workers=2 # this is the default used by requests_futures
else: ))
self.session = requests.Session()
self.add_auth_information() self.add_auth_information()
@classmethod @classmethod
@@ -135,10 +164,8 @@ class BaseHTTPSHandler(logging.Handler):
payload_str = json.dumps(payload_input) payload_str = json.dumps(payload_input)
else: else:
payload_str = payload_input payload_str = payload_input
if self.async: return dict(data=payload_str, background_callback=unused_callback,
return dict(data=payload_str, background_callback=unused_callback) timeout=self.http_timeout)
else:
return dict(data=payload_str)
def skip_log(self, logger_name): def skip_log(self, logger_name):
if self.host == '' or (not self.enabled_flag): if self.host == '' or (not self.enabled_flag):
@@ -153,10 +180,6 @@ class BaseHTTPSHandler(logging.Handler):
Emit a log record. Returns a list of zero or more Emit a log record. Returns a list of zero or more
``concurrent.futures.Future`` objects. ``concurrent.futures.Future`` objects.
When ``self.async`` is True, the list will contain one
Future object for each HTTP request made. When ``self.async`` is
False, the list will be empty.
See: See:
https://docs.python.org/3/library/concurrent.futures.html#future-objects https://docs.python.org/3/library/concurrent.futures.html#future-objects
http://pythonhosted.org/futures/ http://pythonhosted.org/futures/
@@ -177,17 +200,10 @@ class BaseHTTPSHandler(logging.Handler):
for key in facts_dict: for key in facts_dict:
fact_payload = copy(payload_data) fact_payload = copy(payload_data)
fact_payload.update(facts_dict[key]) fact_payload.update(facts_dict[key])
if self.async: async_futures.append(self._send(fact_payload))
async_futures.append(self._send(fact_payload))
else:
self._send(fact_payload)
return async_futures return async_futures
if self.async: return [self._send(payload)]
return [self._send(payload)]
self._send(payload)
return []
except (KeyboardInterrupt, SystemExit): except (KeyboardInterrupt, SystemExit):
raise raise
except: except:
@@ -209,7 +225,7 @@ def add_or_remove_logger(address, instance):
specific_logger.handlers.append(instance) specific_logger.handlers.append(instance)
def configure_external_logger(settings_module, async_flag=True, is_startup=True): def configure_external_logger(settings_module, is_startup=True):
is_enabled = settings_module.LOG_AGGREGATOR_ENABLED is_enabled = settings_module.LOG_AGGREGATOR_ENABLED
if is_startup and (not is_enabled): if is_startup and (not is_enabled):
@@ -218,7 +234,7 @@ def configure_external_logger(settings_module, async_flag=True, is_startup=True)
instance = None instance = None
if is_enabled: if is_enabled:
instance = BaseHTTPSHandler.from_django_settings(settings_module, async=async_flag) instance = BaseHTTPSHandler.from_django_settings(settings_module)
instance.setFormatter(LogstashFormatter(settings_module=settings_module)) instance.setFormatter(LogstashFormatter(settings_module=settings_module))
awx_logger_instance = instance awx_logger_instance = instance
if is_enabled and 'awx' not in settings_module.LOG_AGGREGATOR_LOGGERS: if is_enabled and 'awx' not in settings_module.LOG_AGGREGATOR_LOGGERS:

View File

@@ -25,7 +25,7 @@
- name: update project using git and accept hostkey - name: update project using git and accept hostkey
git: git:
dest: "{{project_path|quote}}" dest: "{{project_path|quote}}"
repo: "{{scm_url|quote}}" repo: "{{scm_url}}"
version: "{{scm_branch|quote}}" version: "{{scm_branch|quote}}"
force: "{{scm_clean}}" force: "{{scm_clean}}"
accept_hostkey: "{{scm_accept_hostkey}}" accept_hostkey: "{{scm_accept_hostkey}}"
@@ -42,7 +42,7 @@
- name: update project using git - name: update project using git
git: git:
dest: "{{project_path|quote}}" dest: "{{project_path|quote}}"
repo: "{{scm_url|quote}}" repo: "{{scm_url}}"
version: "{{scm_branch|quote}}" version: "{{scm_branch|quote}}"
force: "{{scm_clean}}" force: "{{scm_clean}}"
#clone: "{{ scm_full_checkout }}" #clone: "{{ scm_full_checkout }}"
@@ -160,6 +160,11 @@
scm_version: "{{scm_version|regex_replace('^.*Revision: ([0-9]+).*$', '\\1')}}" scm_version: "{{scm_version|regex_replace('^.*Revision: ([0-9]+).*$', '\\1')}}"
when: scm_type == 'svn' when: scm_type == 'svn'
- name: parse hg version string properly
set_fact:
scm_version: "{{scm_version|regex_replace('^([A-Za-z0-9]+).*$', '\\1')}}"
when: scm_type == 'hg'
- name: Repository Version - name: Repository Version
debug: msg="Repository Version {{ scm_version }}" debug: msg="Repository Version {{ scm_version }}"
when: scm_version is defined when: scm_version is defined

View File

@@ -25,7 +25,7 @@ if ([System.IntPtr]::Size -eq 4) {
# This is a 32-bit Windows system, so we only check for 32-bit programs, which will be # This is a 32-bit Windows system, so we only check for 32-bit programs, which will be
# at the native registry location. # at the native registry location.
$packages = Get-ChildItem -Path $uninstall_native_path | [PSObject []]$packages = Get-ChildItem -Path $uninstall_native_path |
Get-ItemProperty | Get-ItemProperty |
Select-Object -Property @{Name="name"; Expression={$_."DisplayName"}}, Select-Object -Property @{Name="name"; Expression={$_."DisplayName"}},
@{Name="version"; Expression={$_."DisplayVersion"}}, @{Name="version"; Expression={$_."DisplayVersion"}},
@@ -38,7 +38,7 @@ if ([System.IntPtr]::Size -eq 4) {
# This is a 64-bit Windows system, so we check for 64-bit programs in the native # This is a 64-bit Windows system, so we check for 64-bit programs in the native
# registry location, and also for 32-bit programs under Wow6432Node. # registry location, and also for 32-bit programs under Wow6432Node.
$packages = Get-ChildItem -Path $uninstall_native_path | [PSObject []]$packages = Get-ChildItem -Path $uninstall_native_path |
Get-ItemProperty | Get-ItemProperty |
Select-Object -Property @{Name="name"; Expression={$_."DisplayName"}}, Select-Object -Property @{Name="name"; Expression={$_."DisplayName"}},
@{Name="version"; Expression={$_."DisplayVersion"}}, @{Name="version"; Expression={$_."DisplayVersion"}},

View File

@@ -867,6 +867,7 @@ INSIGHTS_URL_BASE = "https://access.redhat.com"
TOWER_SETTINGS_MANIFEST = {} TOWER_SETTINGS_MANIFEST = {}
LOG_AGGREGATOR_ENABLED = False LOG_AGGREGATOR_ENABLED = False
LOG_AGGREGATOR_HTTP_TIMEOUT = 5
# The number of retry attempts for websocket session establishment # The number of retry attempts for websocket session establishment
# If you're encountering issues establishing websockets in clustered Tower, # If you're encountering issues establishing websockets in clustered Tower,

View File

@@ -18,7 +18,9 @@ export default ['$rootScope', '$scope', 'GetBasePath', 'Rest', '$q', 'Wait', 'Pr
// the object permissions are being added to // the object permissions are being added to
scope.object = scope.resourceData.data; scope.object = scope.resourceData.data;
// array for all possible roles for the object // array for all possible roles for the object
scope.roles = scope.object.summary_fields.object_roles; scope.roles = _.omit(scope.object.summary_fields.object_roles, (key) => {
return key.name === 'Read';
});
// TODO: get working with api // TODO: get working with api
// array w roles and descriptions for key // array w roles and descriptions for key

View File

@@ -16,7 +16,6 @@ export default
roles: '=', roles: '=',
model: '=' model: '='
}, },
// @issue why is the read-only role ommited from this selection?
template: '<select ng-cloak class="AddPermissions-selectHide roleSelect2 form-control" ng-model="model" ng-options="role.name for role in roles track by role.id" multiple required></select>', template: '<select ng-cloak class="AddPermissions-selectHide roleSelect2 form-control" ng-model="model" ng-options="role.name for role in roles track by role.id" multiple required></select>',
link: function(scope, element, attrs, ctrl) { link: function(scope, element, attrs, ctrl) {
CreateSelect2({ CreateSelect2({

View File

@@ -23,6 +23,7 @@ export default ['$scope', '$rootScope', '$compile', '$location',
init(); init();
function init() { function init() {
$scope.canEditOrg = true;
// Load the list of options for Kind // Load the list of options for Kind
GetChoices({ GetChoices({
scope: $scope, scope: $scope,

View File

@@ -55,7 +55,8 @@ export default
dataTitle: i18n._('Organization') + ' ', dataTitle: i18n._('Organization') + ' ',
dataPlacement: 'bottom', dataPlacement: 'bottom',
dataContainer: "body", dataContainer: "body",
ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd)' ngDisabled: '!(credential_obj.summary_fields.user_capabilities.edit || canAdd) || !canEditOrg',
awLookupWhen: '(credential_obj.summary_fields.user_capabilities.edit || canAdd) && canEditOrg'
}, },
kind: { kind: {
label: i18n._('Type'), label: i18n._('Type'),

View File

@@ -49,7 +49,8 @@ angular.module('InventoryFormDefinition', [])
reqExpression: "organizationrequired", reqExpression: "organizationrequired",
init: "true" init: "true"
}, },
ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd)' ngDisabled: '!(inventory_obj.summary_fields.user_capabilities.edit || canAdd) || !canEditOrg',
awLookupWhen: '(inventory_obj.summary_fields.user_capabilities.edit || canAdd) && canEditOrg'
}, },
variables: { variables: {
label: i18n._('Variables'), label: i18n._('Variables'),

View File

@@ -349,7 +349,11 @@ export default
dataPlacement: 'right', dataPlacement: 'right',
dataTitle: i18n._("Host Config Key"), dataTitle: i18n._("Host Config Key"),
dataContainer: "body", dataContainer: "body",
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)',
awRequiredWhen: {
reqExpression: 'allow_callbacks',
alwaysShowAsterisk: true
}
}, },
labels: { labels: {
label: i18n._('Labels'), label: i18n._('Labels'),

View File

@@ -42,7 +42,8 @@ export default
sourceModel: 'organization', sourceModel: 'organization',
basePath: 'organizations', basePath: 'organizations',
sourceField: 'name', sourceField: 'name',
ngDisabled: '!(team_obj.summary_fields.user_capabilities.edit || canAdd)', ngDisabled: '!(team_obj.summary_fields.user_capabilities.edit || canAdd) || !canEditOrg',
awLookupWhen: '(team_obj.summary_fields.user_capabilities.edit || canAdd) && canEditOrg',
required: true, required: true,
} }
}, },

View File

@@ -54,7 +54,8 @@ export default
dataContainer: 'body', dataContainer: 'body',
dataPlacement: 'right', dataPlacement: 'right',
column: 1, column: 1,
ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate)' ngDisabled: '!(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) || !canEditOrg',
awLookupWhen: '(workflow_job_template_obj.summary_fields.user_capabilities.edit || canAddWorkflowJobTemplate) && canEditOrg'
}, },
labels: { labels: {
label: i18n._('Labels'), label: i18n._('Labels'),

View File

@@ -39,6 +39,7 @@ function InventoriesAdd($scope, $rootScope, $compile, $location, $log,
init(); init();
function init() { function init() {
$scope.canEditOrg = true;
form.formLabelSize = null; form.formLabelSize = null;
form.formFieldSize = null; form.formFieldSize = null;

View File

@@ -14,7 +14,7 @@ function InventoriesEdit($scope, $rootScope, $compile, $location,
$log, $stateParams, InventoryForm, Rest, Alert, ProcessErrors, $log, $stateParams, InventoryForm, Rest, Alert, ProcessErrors,
ClearScope, GetBasePath, ParseTypeChange, Wait, ToJSON, ClearScope, GetBasePath, ParseTypeChange, Wait, ToJSON,
ParseVariableString, Prompt, InitiatePlaybookRun, ParseVariableString, Prompt, InitiatePlaybookRun,
TemplatesService, $state) { TemplatesService, $state, OrgAdminLookup) {
// Inject dynamic view // Inject dynamic view
var defaultUrl = GetBasePath('inventory'), var defaultUrl = GetBasePath('inventory'),
@@ -77,6 +77,11 @@ function InventoriesEdit($scope, $rootScope, $compile, $location,
field_id: 'inventory_variables' field_id: 'inventory_variables'
}); });
OrgAdminLookup.checkForAdminAccess({organization: data.organization})
.then(function(canEditOrg){
$scope.canEditOrg = canEditOrg;
});
$scope.inventory_obj = data; $scope.inventory_obj = data;
$scope.name = data.name; $scope.name = data.name;
@@ -132,5 +137,5 @@ export default ['$scope', '$rootScope', '$compile', '$location',
'$log', '$stateParams', 'InventoryForm', 'Rest', 'Alert', '$log', '$stateParams', 'InventoryForm', 'Rest', 'Alert',
'ProcessErrors', 'ClearScope', 'GetBasePath', 'ParseTypeChange', 'Wait', 'ProcessErrors', 'ClearScope', 'GetBasePath', 'ParseTypeChange', 'Wait',
'ToJSON', 'ParseVariableString', 'Prompt', 'InitiatePlaybookRun', 'ToJSON', 'ParseVariableString', 'Prompt', 'InitiatePlaybookRun',
'TemplatesService', '$state', InventoriesEdit, 'TemplatesService', '$state', 'OrgAdminLookup', InventoriesEdit,
]; ];

View File

@@ -46,6 +46,9 @@ angular.module('inventory', [
data: { data: {
activityStream: true, activityStream: true,
activityStreamTarget: 'inventory' activityStreamTarget: 'inventory'
},
ncyBreadcrumb: {
label: N_('INVENTORIES')
} }
}); });

View File

@@ -12,7 +12,7 @@
function adhocController($q, $scope, $location, $stateParams, function adhocController($q, $scope, $location, $stateParams,
$state, CheckPasswords, PromptForPasswords, CreateLaunchDialog, CreateSelect2, adhocForm, $state, CheckPasswords, PromptForPasswords, CreateLaunchDialog, CreateSelect2, adhocForm,
GenerateForm, Rest, ProcessErrors, ClearScope, GetBasePath, GetChoices, GenerateForm, Rest, ProcessErrors, ClearScope, GetBasePath, GetChoices,
KindChange, CredentialList, Empty, Wait) { KindChange, CredentialList, ParseTypeChange, Empty, Wait) {
ClearScope(); ClearScope();
@@ -162,6 +162,12 @@ function adhocController($q, $scope, $location, $stateParams,
privateFn.initializeForm(id, urls, hostPattern); privateFn.initializeForm(id, urls, hostPattern);
// init codemirror
$scope.extra_vars = '---';
$scope.parseType = 'yaml';
$scope.envParseType = 'yaml';
ParseTypeChange({ scope: $scope, field_id: 'adhoc_extra_vars' , variable: "extra_vars"});
$scope.formCancel = function(){ $scope.formCancel = function(){
$state.go('inventoryManage'); $state.go('inventoryManage');
}; };
@@ -199,6 +205,7 @@ function adhocController($q, $scope, $location, $stateParams,
"module_args": "", "module_args": "",
"forks": 0, "forks": 0,
"verbosity": 0, "verbosity": 0,
"extra_vars": "",
"privilege_escalation": "" "privilege_escalation": ""
}; };
@@ -297,5 +304,5 @@ function adhocController($q, $scope, $location, $stateParams,
export default ['$q', '$scope', '$location', '$stateParams', export default ['$q', '$scope', '$location', '$stateParams',
'$state', 'CheckPasswords', 'PromptForPasswords', 'CreateLaunchDialog', 'CreateSelect2', '$state', 'CheckPasswords', 'PromptForPasswords', 'CreateLaunchDialog', 'CreateSelect2',
'adhocForm', 'GenerateForm', 'Rest', 'ProcessErrors', 'ClearScope', 'GetBasePath', 'adhocForm', 'GenerateForm', 'Rest', 'ProcessErrors', 'ClearScope', 'GetBasePath',
'GetChoices', 'KindChange', 'CredentialList', 'Empty', 'Wait', 'GetChoices', 'KindChange', 'CredentialList', 'ParseTypeChange', 'Empty', 'Wait',
adhocController]; adhocController];

View File

@@ -10,7 +10,7 @@
* @description This form is for executing an adhoc command * @description This form is for executing an adhoc command
*/ */
export default function() { export default ['i18n', function(i18n) {
return { return {
addTitle: 'EXECUTE COMMAND', addTitle: 'EXECUTE COMMAND',
name: 'adhoc', name: 'adhoc',
@@ -121,6 +121,23 @@ export default function() {
dataPlacement: 'right', dataPlacement: 'right',
dataContainer: "body" dataContainer: "body"
}, },
extra_vars: {
label: i18n._('Extra Variables'),
type: 'textarea',
class: 'Form-textAreaLabel Form-formGroup--fullWidth',
rows: 6,
"default": "---",
column: 2,
awPopOver: "<p>" + i18n.sprintf(i18n._("Pass extra command line variables. This is the %s or %s command line parameter " +
"for %s. Provide key/value pairs using either YAML or JSON."), '<code>-e</code>', '<code>--extra-vars</code>', '<code>ansible</code>') + "</p>" +
"JSON:<br />\n" +
"<blockquote>{<br />&emsp;\"somevar\": \"somevalue\",<br />&emsp;\"password\": \"magic\"<br /> }</blockquote>\n" +
"YAML:<br />\n" +
"<blockquote>---<br />somevar: somevalue<br />password: magic<br /></blockquote>\n",
dataTitle: i18n._('Extra Variables'),
dataPlacement: 'right',
dataContainer: "body"
}
}, },
buttons: { buttons: {
reset: { reset: {
@@ -139,4 +156,4 @@ export default function() {
related: {} related: {}
}; };
} }];

View File

@@ -1,6 +1,6 @@
<div class="BreadCrumb InventoryManageBreadCrumbs"> <div class="BreadCrumb InventoryManageBreadCrumbs">
<ol class="BreadCrumb-list"> <ol class="BreadCrumb-list">
<li class="BreadCrumb-item"><a ui-sref="inventories">Inventories</a></li> <li class="BreadCrumb-item"><a ui-sref="inventories">INVENTORIES</a></li>
<li class="BreadCrumb-item BreadCrumb-invItem"> <li class="BreadCrumb-item BreadCrumb-invItem">
<a href ng-if="currentState !== 'inventoryManage' || groups.length > 0" ng-click="goToInventory()">{{inventory.name}}</a> <a href ng-if="currentState !== 'inventoryManage' || groups.length > 0" ng-click="goToInventory()">{{inventory.name}}</a>
<span ng-if="currentState === 'inventoryManage' && groups.length === 0">{{inventory.name}}</span> <span ng-if="currentState === 'inventoryManage' && groups.length === 0">{{inventory.name}}</span>

View File

@@ -14,7 +14,7 @@
</button> </button>
</div> </div>
<div class="HostEvent-details--left"> <div class="HostEvent-details">
<div class="HostEvent-field"> <div class="HostEvent-field">
<span class="HostEvent-field--label">CREATED</span> <span class="HostEvent-field--label">CREATED</span>
<span class="HostEvent-field--content">{{(event.created | longDate) || "No result found"}}</span> <span class="HostEvent-field--content">{{(event.created | longDate) || "No result found"}}</span>

View File

@@ -99,7 +99,7 @@
margin-bottom: 8px; margin-bottom: 8px;
} }
.HostEvent .modal-body{ .HostEvent .modal-body{
max-height: 500px; max-height: 600px;
padding: 0px!important; padding: 0px!important;
overflow-y: auto; overflow-y: auto;
} }
@@ -115,6 +115,7 @@
text-transform: uppercase; text-transform: uppercase;
flex: 0 1 80px; flex: 0 1 80px;
max-width: 80px; max-width: 80px;
min-width: 80px;
font-size: 12px; font-size: 12px;
word-wrap: break-word; word-wrap: break-word;
} }
@@ -123,28 +124,10 @@
} }
.HostEvent-field--content{ .HostEvent-field--content{
word-wrap: break-word; word-wrap: break-word;
max-width: 13em;
flex: 0 1 13em;
} }
.HostEvent-field--monospaceContent{ .HostEvent-field--monospaceContent{
font-family: monospace; font-family: monospace;
} }
.HostEvent-details--left, .HostEvent-details--right{
flex: 1 1 47%;
}
.HostEvent-details--left{
margin-right: 40px;
}
.HostEvent-details--right{
.HostEvent-field--label{
flex: 0 1 25em;
}
.HostEvent-field--content{
max-width: 15em;
flex: 0 1 15em;
align-self: flex-end;
}
}
.HostEvent-button:disabled { .HostEvent-button:disabled {
pointer-events: all!important; pointer-events: all!important;
} }

View File

@@ -213,23 +213,8 @@ job-results-standard-out {
color: @default-icon; color: @default-icon;
} }
.JobResults .CodeMirror.cm-s-default,
.JobResults .CodeMirror-line {
background-color: #f6f6f6;
}
.JobResults .CodeMirror-gutter.CodeMirror-lint-markers,
.JobResults .CodeMirror-gutter.CodeMirror-linenumbers {
background-color: #ebebeb;
color: @b7grey;
}
.JobResults .CodeMirror-lines { .JobResults .CodeMirror-lines {
cursor: default; cursor: not-allowed;
}
.JobResults .CodeMirror-cursors {
display: none;
} }
.JobResults-downloadTooLarge { .JobResults-downloadTooLarge {

View File

@@ -175,18 +175,12 @@
background-color: @btn-bg-hov; background-color: @btn-bg-hov;
color: @btn-txt; color: @btn-txt;
} }
.JobSubmission-revertButton {
background-color: @default-bg; .JobSubmission-revertLink {
color: @default-link; padding-left:10px;
text-transform: uppercase;
padding-left:15px;
padding-right: 15px;
font-size: 11px; font-size: 11px;
} }
.JobSubmission-revertButton:hover{
background-color: @default-bg;
color: @default-link-hov;
}
.JobSubmission-selectedItem { .JobSubmission-selectedItem {
display: flex; display: flex;
flex: 1 0 auto; flex: 1 0 auto;

View File

@@ -316,15 +316,6 @@ export default
$scope.revertToDefaultInventory = function() { $scope.revertToDefaultInventory = function() {
if($scope.has_default_inventory) { if($scope.has_default_inventory) {
$scope.selected_inventory = angular.copy($scope.defaults.inventory); $scope.selected_inventory = angular.copy($scope.defaults.inventory);
// Loop across inventories and set update the "checked" attribute for each row
$scope.inventories.forEach(function(row, i) {
if (row.id === $scope.selected_inventory.id) {
$scope.inventories[i].checked = 1;
} else {
$scope.inventories[i].checked = 0;
}
});
} }
}; };
@@ -332,15 +323,6 @@ export default
if($scope.has_default_credential) { if($scope.has_default_credential) {
$scope.selected_credential = angular.copy($scope.defaults.credential); $scope.selected_credential = angular.copy($scope.defaults.credential);
updateRequiredPasswords(); updateRequiredPasswords();
// Loop across credentials and set update the "checked" attribute for each row
$scope.credentials.forEach(function(row, i) {
if (row.id === $scope.selected_credential.id) {
$scope.credentials[i].checked = 1;
} else {
$scope.credentials[i].checked = 0;
}
});
} }
}; };

View File

@@ -26,10 +26,10 @@
<span class="JobSubmission-selectedItemNone" ng-show="!selected_inventory">None selected</span> <span class="JobSubmission-selectedItemNone" ng-show="!selected_inventory">None selected</span>
</div> </div>
<div class="JobSubmission-selectedItemRevert" ng-if="ask_inventory_on_launch && has_default_inventory"> <div class="JobSubmission-selectedItemRevert" ng-if="ask_inventory_on_launch && has_default_inventory">
<button class="btn btn-xs JobSubmission-revertButton" ng-hide="selected_inventory.id === defaults.inventory.id" ng-click="revertToDefaultInventory()">REVERT TO DEFAULT</button> <a class="Form-labelAction JobSubmission-revertLink" href="" ng-hide="selected_inventory.id === defaults.inventory.id" ng-click="revertToDefaultInventory()">REVERT</a>
</div> </div>
</div> </div>
<job-sub-inv-list ng-if="includeInventoryList"></job-sub-inv-list> <job-sub-inv-list ng-if="includeInventoryList" selected-inventory="$parent.selected_inventory"></job-sub-inv-list>
</div> </div>
</div> </div>
<div ng-if="ask_credential_on_launch || password_needed" ng-show="step === 'credential'" class="JobSubmission-form"> <div ng-if="ask_credential_on_launch || password_needed" ng-show="step === 'credential'" class="JobSubmission-form">
@@ -41,10 +41,10 @@
<span class="JobSubmission-selectedItemNone" ng-show="!selected_credential">None selected</span> <span class="JobSubmission-selectedItemNone" ng-show="!selected_credential">None selected</span>
</div> </div>
<div class="JobSubmission-selectedItemRevert" ng-if="ask_credential_on_launch && has_default_credential"> <div class="JobSubmission-selectedItemRevert" ng-if="ask_credential_on_launch && has_default_credential">
<button class="btn btn-xs JobSubmission-revertButton" ng-hide="selected_credential.id === defaults.credential.id" ng-click="revertToDefaultCredential()">REVERT TO DEFAULT</button> <a class="Form-labelAction JobSubmission-revertLink" href="" ng-hide="selected_credential.id === defaults.credential.id" ng-click="revertToDefaultCredential()">REVERT</a>
</div> </div>
</div> </div>
<job-sub-cred-list ng-if="includeCredentialList"></job-sub-cred-list> <job-sub-cred-list ng-if="includeCredentialList" selected-credential="$parent.selected_credential"></job-sub-cred-list>
<div ng-show="ssh_password_required || ssh_key_unlock_required || become_password_required || vault_password_required"> <div ng-show="ssh_password_required || ssh_key_unlock_required || become_password_required || vault_password_required">
<div class="JobSubmission-instructions">Launching this job requires the passwords listed below. Enter and confirm each password before continuing.</div> <div class="JobSubmission-instructions">Launching this job requires the passwords listed below. Enter and confirm each password before continuing.</div>
<form name="forms.credentialpasswords" autocomplete="off" novalidate> <form name="forms.credentialpasswords" autocomplete="off" novalidate>

View File

@@ -7,13 +7,17 @@
import jobSubCredListController from './job-sub-cred-list.controller'; import jobSubCredListController from './job-sub-cred-list.controller';
export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'CredentialList', export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'CredentialList',
function(templateUrl, qs, GetBasePath, GenerateList, $compile, CredentialList) { (templateUrl, qs, GetBasePath, GenerateList, $compile, CredentialList) => {
return { return {
scope: {}, scope: {
selectedCredential: '='
},
templateUrl: templateUrl('job-submission/lists/credential/job-sub-cred-list'), templateUrl: templateUrl('job-submission/lists/credential/job-sub-cred-list'),
controller: jobSubCredListController, controller: jobSubCredListController,
restrict: 'E', restrict: 'E',
link: function(scope) { link: scope => {
let toDestroy = [];
scope.credential_default_params = { scope.credential_default_params = {
order_by: 'name', order_by: 'name',
page_size: 5, page_size: 5,
@@ -28,11 +32,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
// Fire off the initial search // Fire off the initial search
qs.search(GetBasePath('credentials'), scope.credential_default_params) qs.search(GetBasePath('credentials'), scope.credential_default_params)
.then(function(res) { .then(res => {
scope.credential_dataset = res.data; scope.credential_dataset = res.data;
scope.credentials = scope.credential_dataset.results; scope.credentials = scope.credential_dataset.results;
var credList = _.cloneDeep(CredentialList); let credList = _.cloneDeep(CredentialList);
let html = GenerateList.build({ let html = GenerateList.build({
list: credList, list: credList,
input_type: 'radio', input_type: 'radio',
@@ -43,11 +47,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
$('#job-submission-credential-lookup').append($compile(html)(scope)); $('#job-submission-credential-lookup').append($compile(html)(scope));
scope.$watchCollection('credentials', function () { toDestroy.push(scope.$watchCollection('selectedCredential', () => {
if(scope.selected_credential) { if(scope.selectedCredential) {
// Loop across the inventories and see if one of them should be "checked" // Loop across the inventories and see if one of them should be "checked"
scope.credentials.forEach(function(row, i) { scope.credentials.forEach((row, i) => {
if (row.id === scope.selected_credential.id) { if (row.id === scope.selectedCredential.id) {
scope.credentials[i].checked = 1; scope.credentials[i].checked = 1;
} }
else { else {
@@ -55,9 +59,10 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
} }
}); });
} }
}); }));
}); });
scope.$on('$destroy', () => toDestroy.forEach(watcher => watcher()));
} }
}; };
}]; }];

View File

@@ -7,13 +7,17 @@
import jobSubInvListController from './job-sub-inv-list.controller'; import jobSubInvListController from './job-sub-inv-list.controller';
export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'InventoryList', export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'InventoryList',
function(templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList) { (templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList) => {
return { return {
scope: {}, scope: {
selectedInventory: '='
},
templateUrl: templateUrl('job-submission/lists/inventory/job-sub-inv-list'), templateUrl: templateUrl('job-submission/lists/inventory/job-sub-inv-list'),
controller: jobSubInvListController, controller: jobSubInvListController,
restrict: 'E', restrict: 'E',
link: function(scope) { link: scope => {
let toDestroy = [];
scope.inventory_default_params = { scope.inventory_default_params = {
order_by: 'name', order_by: 'name',
page_size: 5 page_size: 5
@@ -26,11 +30,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
// Fire off the initial search // Fire off the initial search
qs.search(GetBasePath('inventory'), scope.inventory_default_params) qs.search(GetBasePath('inventory'), scope.inventory_default_params)
.then(function(res) { .then(res => {
scope.inventory_dataset = res.data; scope.inventory_dataset = res.data;
scope.inventories = scope.inventory_dataset.results; scope.inventories = scope.inventory_dataset.results;
var invList = _.cloneDeep(InventoryList); let invList = _.cloneDeep(InventoryList);
let html = GenerateList.build({ let html = GenerateList.build({
list: invList, list: invList,
input_type: 'radio', input_type: 'radio',
@@ -41,11 +45,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
$('#job-submission-inventory-lookup').append($compile(html)(scope)); $('#job-submission-inventory-lookup').append($compile(html)(scope));
scope.$watchCollection('inventories', function () { toDestroy.push(scope.$watchCollection('selectedInventory', () => {
if(scope.selected_inventory) { if(scope.selectedInventory) {
// Loop across the inventories and see if one of them should be "checked" // Loop across the inventories and see if one of them should be "checked"
scope.inventories.forEach(function(row, i) { scope.inventories.forEach((row, i) => {
if (row.id === scope.selected_inventory.id) { if (row.id === scope.selectedInventory.id) {
scope.inventories[i].checked = 1; scope.inventories[i].checked = 1;
} }
else { else {
@@ -53,8 +57,10 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
} }
}); });
} }
}); }));
}); });
scope.$on('$destroy', () => toDestroy.forEach(watcher => watcher()));
} }
}; };
}]; }];

View File

@@ -5,9 +5,9 @@
*************************************************/ *************************************************/
export default export default
['$state', '$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', '$q', ['$state', '$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors',
'ConfigService', 'ConfigService',
function($state, $rootScope, Rest, GetBasePath, ProcessErrors, $q, function($state, $rootScope, Rest, GetBasePath, ProcessErrors,
ConfigService){ ConfigService){
return { return {
get: function() { get: function() {

View File

@@ -19,6 +19,7 @@ export default ['$scope', '$location', '$stateParams', 'GenerateForm',
init(); init();
function init() { function init() {
$scope.canEditOrg = true;
Rest.setUrl(GetBasePath('projects')); Rest.setUrl(GetBasePath('projects'));
Rest.options() Rest.options()
.success(function(data) { .success(function(data) {

View File

@@ -49,7 +49,8 @@ export default ['i18n', 'NotificationsList', function(i18n, NotificationsList) {
required: true, required: true,
dataContainer: 'body', dataContainer: 'body',
dataPlacement: 'right', dataPlacement: 'right',
ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)' ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd) || !canEditOrg',
awLookupWhen: '(project_obj.summary_fields.user_capabilities.edit || canAdd) && canEditOrg'
}, },
scm_type: { scm_type: {
label: i18n._('SCM Type'), label: i18n._('SCM Type'),
@@ -134,6 +135,11 @@ export default ['i18n', 'NotificationsList', function(i18n, NotificationsList) {
search: { search: {
kind: 'scm' kind: 'scm'
}, },
autopopulateLookup: false,
awRequiredWhen: {
reqExpression: "credRequired",
init: false
},
ngShow: "scm_type && scm_type.value !== 'manual'", ngShow: "scm_type && scm_type.value !== 'manual'",
sourceModel: 'credential', sourceModel: 'credential',
awLookupType: 'scm_credential', awLookupType: 'scm_credential',

View File

@@ -95,6 +95,7 @@ export default ['i18n', function(i18n) {
awToolTip: "{{ project.scm_schedule_tooltip }}", awToolTip: "{{ project.scm_schedule_tooltip }}",
ngClass: "project.scm_type_class", ngClass: "project.scm_type_class",
dataPlacement: 'top', dataPlacement: 'top',
ngShow: "project.summary_fields.user_capabilities.schedule"
}, },
edit: { edit: {
ngClick: "editProject(project.id)", ngClick: "editProject(project.id)",

View File

@@ -493,7 +493,8 @@ function(ConfigurationUtils, i18n, $rootScope) {
modelName = attrs.source, modelName = attrs.source,
lookupType = attrs.awlookuptype, lookupType = attrs.awlookuptype,
watcher = attrs.awRequiredWhen || undefined, watcher = attrs.awRequiredWhen || undefined,
watchBasePath; watchBasePath,
awLookupWhen = attrs.awLookupWhen;
if (attrs.autopopulatelookup !== undefined) { if (attrs.autopopulatelookup !== undefined) {
autopopulateLookup = JSON.parse(attrs.autopopulatelookup); autopopulateLookup = JSON.parse(attrs.autopopulatelookup);
@@ -501,7 +502,6 @@ function(ConfigurationUtils, i18n, $rootScope) {
autopopulateLookup = true; autopopulateLookup = true;
} }
// The following block of code is for instances where the // The following block of code is for instances where the
// lookup field is reused by varying sub-forms. Example: The groups // lookup field is reused by varying sub-forms. Example: The groups
// form will change it's credential lookup based on the // form will change it's credential lookup based on the
@@ -602,7 +602,12 @@ function(ConfigurationUtils, i18n, $rootScope) {
// form.$pending will contain object reference to any ngModelControllers with outstanding requests // form.$pending will contain object reference to any ngModelControllers with outstanding requests
fieldCtrl.$asyncValidators.validResource = function(modelValue, viewValue) { fieldCtrl.$asyncValidators.validResource = function(modelValue, viewValue) {
applyValidationStrategy(viewValue, fieldCtrl); if(awLookupWhen === undefined || (awLookupWhen !== undefined && Boolean(scope.$eval(awLookupWhen)) === true)) {
applyValidationStrategy(viewValue, fieldCtrl);
}
else {
defer.resolve();
}
return defer.promise; return defer.promise;
}; };

View File

@@ -1388,6 +1388,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
html += (field.autopopulateLookup !== undefined) ? ` autopopulateLookup=${field.autopopulateLookup} ` : ""; html += (field.autopopulateLookup !== undefined) ? ` autopopulateLookup=${field.autopopulateLookup} ` : "";
html += (field.watchBasePath !== undefined) ? ` watchBasePath=${field.watchBasePath} ` : ""; html += (field.watchBasePath !== undefined) ? ` watchBasePath=${field.watchBasePath} ` : "";
html += `ng-model-options="{ updateOn: 'default blur', debounce: { 'default': 300, 'blur': 0 } }"`; html += `ng-model-options="{ updateOn: 'default blur', debounce: { 'default': 300, 'blur': 0 } }"`;
html += (field.awLookupWhen !== undefined) ? this.attr(field, 'awLookupWhen') : "";
html += " awlookup >\n"; html += " awlookup >\n";
html += "</div>\n"; html += "</div>\n";

View File

@@ -86,6 +86,9 @@ angular.module('GeneratorHelpers', [systemStatus.name])
result += value; result += value;
result += '"'; result += '"';
break; break;
case 'awLookupWhen':
result = "ng-attr-awlookup=\"" + value + "\" ";
break;
default: default:
result = key + "=\"" + value + "\" "; result = key + "=\"" + value + "\" ";
} }

View File

@@ -29,13 +29,14 @@ import config from './config/main';
import PromptDialog from './prompt-dialog'; import PromptDialog from './prompt-dialog';
import directives from './directives'; import directives from './directives';
import features from './features/main'; import features from './features/main';
import orgAdminLookup from './org-admin-lookup/main';
import 'angular-duration-format'; import 'angular-duration-format';
export default export default
angular.module('shared', [listGenerator.name, angular.module('shared', [listGenerator.name,
formGenerator.name, formGenerator.name,
lookupModal.name, lookupModal.name,
smartSearch.name, smartSearch.name,
paginate.name, paginate.name,
columnSort.name, columnSort.name,
filters.name, filters.name,
@@ -55,6 +56,7 @@ angular.module('shared', [listGenerator.name,
directives.name, directives.name,
filters.name, filters.name,
features.name, features.name,
orgAdminLookup.name,
require('angular-cookies'), require('angular-cookies'),
'angular-duration-format' 'angular-duration-format'
]) ])

View File

@@ -0,0 +1,11 @@
/*************************************************
* Copyright (c) 2017 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import OrgAdminLookupFactory from './org-admin-lookup.factory';
export default
angular.module('orgAdminLookup', [])
.service('OrgAdminLookup', OrgAdminLookupFactory);

View File

@@ -0,0 +1,35 @@
/*************************************************
* Copyright (c) 2017 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
export default
['Rest', 'Authorization', 'GetBasePath', '$rootScope', '$q',
function(Rest, Authorization, GetBasePath, $rootScope, $q){
return {
checkForAdminAccess: function(params) {
// params.organization - id of the organization in question
var deferred = $q.defer();
if(Authorization.getUserInfo('is_superuser') !== true) {
Rest.setUrl(GetBasePath('users') + $rootScope.current_user.id + '/admin_of_organizations');
Rest.get({ params: { id: params.organization } })
.success(function(data) {
if(data.count && data.count > 0) {
deferred.resolve(true);
}
else {
deferred.resolve(false);
}
});
}
else {
deferred.resolve(true);
}
return deferred.promise;
}
};
}
];

View File

@@ -150,7 +150,7 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto
url: url, url: url,
ncyBreadcrumb: { ncyBreadcrumb: {
[params.parent ? 'parent' : null]: `${params.parent}`, [params.parent ? 'parent' : null]: `${params.parent}`,
label: i18n.sprintf(i18n._("CREATE %s"), i18n._(`${form.breadcrumbName || form.name}`)) label: i18n.sprintf(i18n._("CREATE %s"), i18n._(`${form.breadcrumbName || form.name.toUpperCase()}`))
}, },
views: { views: {
'form': { 'form': {
@@ -386,14 +386,15 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto
function buildNotificationState(field) { function buildNotificationState(field) {
let state, let state,
list = field.include ? $injector.get(field.include) : field; list = field.include ? $injector.get(field.include) : field,
breadcrumbLabel = (field.iterator.replace('_', ' ') + 's').toUpperCase();
state = $stateExtender.buildDefinition({ state = $stateExtender.buildDefinition({
searchPrefix: `${list.iterator}`, searchPrefix: `${list.iterator}`,
name: `${formStateDefinition.name}.${list.iterator}s`, name: `${formStateDefinition.name}.${list.iterator}s`,
url: `/${list.iterator}s`, url: `/${list.iterator}s`,
ncyBreadcrumb: { ncyBreadcrumb: {
parent: `${formStateDefinition.name}`, parent: `${formStateDefinition.name}`,
label: `${field.iterator}s` label: `${breadcrumbLabel}`
}, },
params: { params: {
[list.iterator + '_search']: { [list.iterator + '_search']: {
@@ -581,14 +582,14 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto
list = field.include ? $injector.get(field.include) : field, list = field.include ? $injector.get(field.include) : field,
// Added this line specifically for Completed Jobs but should be OK // Added this line specifically for Completed Jobs but should be OK
// for all the rest of the related tabs // for all the rest of the related tabs
breadcrumbLabel = field.iterator.replace('_', ' '), breadcrumbLabel = (field.iterator.replace('_', ' ') + 's').toUpperCase(),
stateConfig = { stateConfig = {
searchPrefix: `${list.iterator}`, searchPrefix: `${list.iterator}`,
name: `${formStateDefinition.name}.${list.iterator}s`, name: `${formStateDefinition.name}.${list.iterator}s`,
url: `/${list.iterator}s`, url: `/${list.iterator}s`,
ncyBreadcrumb: { ncyBreadcrumb: {
parent: `${formStateDefinition.name}`, parent: `${formStateDefinition.name}`,
label: `${breadcrumbLabel}s` label: `${breadcrumbLabel}`
}, },
params: { params: {
[list.iterator + '_search']: { [list.iterator + '_search']: {

View File

@@ -99,6 +99,26 @@
<div class="StandardOut-detailsLabel col-lg-3 col-md-3 col-sm-3 col-xs-4" translate>Verbosity</div> <div class="StandardOut-detailsLabel col-lg-3 col-md-3 col-sm-3 col-xs-4" translate>Verbosity</div>
<div class="StandardOut-detailsContent col-lg-9 col-md-9 col-sm-9 col-xs-8">{{ verbosity }}</div> <div class="StandardOut-detailsContent col-lg-9 col-md-9 col-sm-9 col-xs-8">{{ verbosity }}</div>
</div> </div>
<div class="StandardOut-detailsRow" ng-show="job.extra_vars">
<div class="StandardOut-detailsLabel col-lg-3 col-md-3 col-sm-3 col-xs-4" translate>
Extra Variables
<i class="StandardOut-extraVarsHelp fa fa-question-circle"
aw-tool-tip="Read only view of extra variables added to the ad-hoc command."
data-placement="top">
</i>
</div>
<div class="StandardOut-detailsContent col-lg-9 col-md-9 col-sm-9 col-xs-8">
<textarea
rows="6"
ng-model="extra_vars"
name="variables"
class="StandardOut-extraVars"
id="pre-formatted-variables">
</textarea>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -149,7 +149,11 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams,
} }
if (data.extra_vars) { if (data.extra_vars) {
ParseTypeChange({ scope: $scope, field_id: 'pre-formatted-variables' }); ParseTypeChange({
scope: $scope,
field_id: 'pre-formatted-variables',
readOnly: true
});
} }
if ($scope.job.type === 'inventory_update' && !$scope.inv_manage_group_link) { if ($scope.job.type === 'inventory_update' && !$scope.inv_manage_group_link) {

View File

@@ -28,6 +28,7 @@ export default ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'GenerateFor
init(); init();
function init() { function init() {
$scope.canEditOrg = true;
// apply form definition's default field values // apply form definition's default field values
GenerateForm.applyDefaults(form, $scope); GenerateForm.applyDefaults(form, $scope);

View File

@@ -29,6 +29,7 @@
generator = GenerateForm; generator = GenerateForm;
function init() { function init() {
$scope.canEditOrg = true;
$scope.parseType = 'yaml'; $scope.parseType = 'yaml';
$scope.can_edit = true; $scope.can_edit = true;
// apply form definition's default field values // apply form definition's default field values

View File

@@ -8,12 +8,12 @@
[ '$scope', '$stateParams', 'WorkflowForm', 'GenerateForm', 'Alert', 'ProcessErrors', [ '$scope', '$stateParams', 'WorkflowForm', 'GenerateForm', 'Alert', 'ProcessErrors',
'ClearScope', 'GetBasePath', '$q', 'ParseTypeChange', 'Wait', 'Empty', 'ClearScope', 'GetBasePath', '$q', 'ParseTypeChange', 'Wait', 'Empty',
'ToJSON', 'initSurvey', '$state', 'CreateSelect2', 'ParseVariableString', 'ToJSON', 'initSurvey', '$state', 'CreateSelect2', 'ParseVariableString',
'TemplatesService', 'OrganizationList', 'Rest', 'WorkflowService', 'ToggleNotification', 'TemplatesService', 'OrganizationList', 'Rest', 'WorkflowService', 'ToggleNotification', 'OrgAdminLookup',
function( function(
$scope, $stateParams, WorkflowForm, GenerateForm, Alert, ProcessErrors, $scope, $stateParams, WorkflowForm, GenerateForm, Alert, ProcessErrors,
ClearScope, GetBasePath, $q, ParseTypeChange, Wait, Empty, ClearScope, GetBasePath, $q, ParseTypeChange, Wait, Empty,
ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString, ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString,
TemplatesService, OrganizationList, Rest, WorkflowService, ToggleNotification TemplatesService, OrganizationList, Rest, WorkflowService, ToggleNotification, OrgAdminLookup
) { ) {
ClearScope(); ClearScope();
@@ -145,6 +145,17 @@
workflowJobTemplateData.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField]; workflowJobTemplateData.summary_fields[form.fields[fld].sourceModel][form.fields[fld].sourceField];
} }
} }
if(workflowJobTemplateData.organization) {
OrgAdminLookup.checkForAdminAccess({organization: workflowJobTemplateData.organization})
.then(function(canEditOrg){
$scope.canEditOrg = canEditOrg;
});
}
else {
$scope.canEditOrg = true;
}
Wait('stop'); Wait('stop');
$scope.url = workflowJobTemplateData.url; $scope.url = workflowJobTemplateData.url;
$scope.survey_enabled = workflowJobTemplateData.survey_enabled; $scope.survey_enabled = workflowJobTemplateData.survey_enabled;

View File

@@ -347,6 +347,20 @@ export default [ '$state','moment', '$timeout', '$window',
if(!d.isStartNode) { if(!d.isStartNode) {
let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : ""; let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : "";
if(resourceName && resourceName.length > maxNodeTextLength) { if(resourceName && resourceName.length > maxNodeTextLength) {
// When the graph is initially rendered all the links come after the nodes (when you look at the dom).
// SVG components are painted in order of appearance. There is no concept of z-index, only the order.
// As such, we need to move the nodes after the links so that when the tooltip renders it shows up on top
// of the links and not underneath them. I tried rendering the links before the nodes but that lead to
// some weird link animation that I didn't care to try to fix.
svgGroup.selectAll("g.node").each(function() {
this.parentNode.appendChild(this);
});
// After the nodes have been properly placed after the links, we need to make sure that the node that
// the user is hovering over is at the very end of the list. This way the tooltip will appear on top
// of all other nodes.
svgGroup.selectAll("g.node").sort(function (a) {
return (a.id !== d.id) ? -1 : 1;
});
// Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place // Render the tooltip quickly in the dom and then remove. This lets us know how big the tooltip is so that we can place
// it properly on the workflow // it properly on the workflow
let tooltipDimensionChecker = $("<div style='visibility:hidden;font-size:12px;position:absolute;' class='WorkflowChart-tooltipContents'><span>" + resourceName + "</span></div>"); let tooltipDimensionChecker = $("<div style='visibility:hidden;font-size:12px;position:absolute;' class='WorkflowChart-tooltipContents'><span>" + resourceName + "</span></div>");

View File

@@ -146,21 +146,6 @@
color: @default-icon; color: @default-icon;
} }
.WorkflowResults .CodeMirror.cm-s-default,
.WorkflowResults .CodeMirror-line {
background-color: #f6f6f6;
}
.WorkflowResults .CodeMirror-gutter.CodeMirror-lint-markers,
.WorkflowResults .CodeMirror-gutter.CodeMirror-linenumbers {
background-color: #ebebeb;
color: @b7grey;
}
.WorkflowResults .CodeMirror-lines { .WorkflowResults .CodeMirror-lines {
cursor: default; cursor: not-allowed;
}
.WorkflowResults .CodeMirror-cursors {
display: none;
} }

View File

@@ -49,3 +49,5 @@ slackclient==1.0.2
twilio==5.6.0 twilio==5.6.0
uWSGI==2.0.14 uWSGI==2.0.14
xmltodict==0.10.2 xmltodict==0.10.2
pip==8.1.2
setuptools==23.0.0

View File

@@ -197,4 +197,5 @@ xmltodict==0.10.2
zope.interface==4.3.3 # via twisted zope.interface==4.3.3 # via twisted
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# setuptools # via cryptography, django-polymorphic, python-ldap, zope.interface pip==8.1.2
setuptools==23.0.0

View File

@@ -11,3 +11,5 @@ pyvmomi==6.5
pywinrm[kerberos]==0.2.2 pywinrm[kerberos]==0.2.2
secretstorage==2.3.1 secretstorage==2.3.1
shade==1.13.1 shade==1.13.1
setuptools==23.0.0
pip==8.1.2

View File

@@ -128,4 +128,5 @@ wrapt==1.10.8 # via debtcollector, positional
xmltodict==0.10.2 # via pywinrm xmltodict==0.10.2 # via pywinrm
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# setuptools # via cryptography pip==8.1.2
setuptools==23.0.0

View File

@@ -0,0 +1,5 @@
pbr>=1.8
setuptools_scm>=1.15.0
vcversioner>=2.16.0.0
pytest-runner
isort

View File

@@ -1,3 +1,5 @@
FROM logstash:5-alpine FROM logstash:5-alpine
COPY logstash.conf / COPY logstash.conf /
RUN touch /logstash.log
RUN chown logstash:logstash /logstash.log
CMD ["-f", "/logstash.conf"] CMD ["-f", "/logstash.conf"]

View File

@@ -15,5 +15,8 @@ filter {
} }
output { output {
stdout { codec => rubydebug } stdout { codec => rubydebug }
file {
path => "/logstash.log"
}
} }

View File

@@ -1,11 +1,11 @@
--- ---
version: '2' version: '3'
services: services:
unit-tests: unit-tests:
build: build:
context: ../../../ context: ../../../
dockerfile: tools/docker-compose/unit-tests/Dockerfile dockerfile: tools/docker-compose/unit-tests/Dockerfile
image: gcr.io/ansible-tower-engineering/unit-test-runner:${GIT_BRANCH} image: gcr.io/ansible-tower-engineering/unit-test-runner:${GIT_BRANCH:-latest}
environment: environment:
SWIG_FEATURES: "-cpperraswarn -includeall -I/usr/include/openssl" SWIG_FEATURES: "-cpperraswarn -includeall -I/usr/include/openssl"
TEST_DIRS: awx/main/tests/functional awx/main/tests/unit awx/conf/tests awx/sso/tests TEST_DIRS: awx/main/tests/functional awx/main/tests/unit awx/conf/tests awx/sso/tests

View File

@@ -56,6 +56,14 @@ deps =
commands = commands =
make UI_TEST_MODE=CI test-ui make UI_TEST_MODE=CI test-ui
[testenv:ansible]
deps =
ansible
pytest
-r{toxinidir}/requirements/requirements_ansible.txt
commands =
{envdir}/bin/py.test awx/lib/tests/ -c awx/lib/tests/pytest.ini {posargs}
[testenv:coveralls] [testenv:coveralls]
commands= commands=
coverage combine coverage combine