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
commit a69dfced74
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 \
develop refresh adduser migrate dbchange dbshell runserver celeryd \
receiver test test_unit test_coverage coverage_html test_jenkins dev_build \
release_build release_clean sdist rpmtar mock-rpm mock-srpm rpm-sign \
deb deb-src debian debsign pbuilder reprepro setup_tarball \
virtualbox-ovf virtualbox-centos-7 virtualbox-centos-6 \
clean-bundle setup_bundle_tarball \
receiver test test_unit test_ansible test_coverage coverage_html \
test_jenkins dev_build release_build release_clean sdist rpmtar mock-rpm \
mock-srpm rpm-sign deb deb-src debian debsign pbuilder \
reprepro setup_tarball virtualbox-ovf virtualbox-centos-7 \
virtualbox-centos-6 clean-bundle setup_bundle_tarball \
ui-docker-machine ui-docker ui-release ui-devel \
ui-test ui-deps ui-test-ci ui-test-saucelabs jlaska
@ -264,8 +264,8 @@ virtualenv_ansible:
fi; \
if [ ! -d "$(VENV_BASE)/ansible" ]; then \
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 -I pip==8.1.2; \
$(VENV_BASE)/ansible/bin/pip install $(PIP_OPTIONS) --ignore-installed setuptools==23.0.0 && \
$(VENV_BASE)/ansible/bin/pip install $(PIP_OPTIONS) --ignore-installed pip==8.1.2; \
fi; \
fi
@ -276,42 +276,32 @@ virtualenv_tower:
fi; \
if [ ! -d "$(VENV_BASE)/tower" ]; then \
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 -I pip==8.1.2; \
$(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --ignore-installed setuptools==23.0.0 && \
$(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --ignore-installed pip==8.1.2; \
fi; \
fi
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 \
. $(VENV_BASE)/ansible/bin/activate; \
$(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; \
$(VENV_BASE)/ansible/bin/pip install pytest; \
fi
# Install third-party requirements needed for Tower's environment.
requirements_tower: virtualenv_tower
if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \
$(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
$(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements.txt
$(VENV_BASE)/tower/bin/pip uninstall --yes -r requirements/requirements_tower_uninstall.txt
requirements_tower_dev:
if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \
$(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
$(VENV_BASE)/tower/bin/pip install -r requirements/requirements_dev.txt
$(VENV_BASE)/tower/bin/pip uninstall --yes -r requirements/requirements_dev_uninstall.txt
requirements: requirements_ansible requirements_tower
requirements_dev: requirements requirements_tower_dev
requirements_dev: requirements requirements_tower_dev requirements_ansible_dev
requirements_test: requirements
@ -482,7 +472,7 @@ check: flake8 pep8 # pyflakes pylint
TEST_DIRS ?= awx/main/tests awx/conf/tests awx/sso/tests
# Run all API unit tests.
test:
test: test_ansible
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \
fi; \
@ -494,6 +484,12 @@ test_unit:
fi; \
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.
test_coverage:
@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)
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
$(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)
@ -722,6 +742,8 @@ mock-srpm: rpmtar rpm-build/$(RPM_NVR).src.rpm
@echo rpm-build/$(RPM_NVR).src.rpm
@echo "#############################################"
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)

View File

@ -316,6 +316,8 @@ class OrderByBackend(BaseFilterBackend):
else:
order_by = (value,)
if order_by:
order_by = self._strip_sensitive_model_fields(queryset.model, order_by)
# Special handling of the type field for ordering. In this
# case, we're not sorting exactly on the type field, but
# given the limited number of views with multiple types,
@ -338,3 +340,16 @@ class OrderByBackend(BaseFilterBackend):
except FieldError as e:
# Return a 400 for invalid field names.
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):
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)
# Permission class should have already validated host_config_key.
job_template = self.get_object()
@ -2727,14 +2728,14 @@ class JobTemplateCallback(GenericAPIView):
return Response(data, status=status.HTTP_400_BAD_REQUEST)
# 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():
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.
kv = {"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)
result = job.signal_start(inventory_sources_already_updated=inventory_sources_already_updated)
if not result:
data = dict(msg=_('Error starting job!'))
return Response(data, status=status.HTTP_400_BAD_REQUEST)
@ -3665,7 +3666,7 @@ class AdHocCommandRelaunch(GenericAPIView):
data = {}
for field in ('job_type', 'inventory_id', 'limit', 'credential_id',
'module_name', 'module_args', 'forks', 'verbosity',
'become_enabled'):
'extra_vars', 'become_enabled'):
if field.endswith('_id'):
data[field[:-3]] = getattr(obj, field)
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',
]
CENSOR_FIELD_WHITELIST = [
'msg',
'failed',
'changed',
'results',
'start',
'end',
'delta',
'cmd',
'_ansible_no_log',
'rc',
'failed_when_result',
'skipped',
'skip_reason',
]
def __init__(self):
super(BaseCallbackModule, self).__init__()
self.task_uuids = set()
@ -85,6 +69,9 @@ class BaseCallbackModule(CallbackBase):
else:
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:
try:
event_context.add_local(event=event, **event_data)
@ -132,7 +119,9 @@ class BaseCallbackModule(CallbackBase):
task_ctx['task_path'] = task.get_path()
except AttributeError:
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_ctx['task_args'] = task_args
if getattr(task, '_role', None):

View File

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

View File

@ -1277,10 +1277,20 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin):
def get_notification_friendly_name(self):
return "Inventory Update"
def cancel(self):
res = super(InventoryUpdate, self).cancel()
def _build_job_explanation(self):
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:
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

View File

@ -310,9 +310,13 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
elif self.variables_needed_to_start:
variables_needed = True
prompting_needed = False
for value in self._ask_for_vars_dict().values():
if value:
prompting_needed = True
# The behavior of provisioning callback should mimic
# that of job template launch, so prompting_needed should
# 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
not self.passwords_needed_to_start and
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
run.
'''
def cancel(self):
res = super(Job, self).cancel()
def cancel(self, job_explanation=None):
res = super(Job, self).cancel(job_explanation=job_explanation)
if self.project_update:
self.project_update.cancel()
self.project_update.cancel(job_explanation=job_explanation)
return res
@ -1139,7 +1143,7 @@ class JobEvent(CreatedModifiedModel):
# Save artifact data to parent job (if provided).
if artifact_dict:
if event_data and isinstance(event_data, dict):
# Note: Core has not added support for marking artifacts as
# Note: Core has not added support for marking artifacts as
# sensitive yet. Going forward, core will not use
# _ansible_no_log to denote sensitive set_stats calls.
# Instead, they plan to add a flag outside of the traditional

View File

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

View File

@ -90,7 +90,7 @@ def celery_startup(conf=None, **kwargs):
@worker_process_init.connect
def task_set_logger_pre_run(*args, **kwargs):
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):
@ -1160,6 +1160,7 @@ class RunProjectUpdate(BaseTask):
'''
env = super(RunProjectUpdate, self).build_env(project_update, **kwargs)
env = self.add_ansible_venv(env)
env['ANSIBLE_RETRY_FILES_ENABLED'] = str(False)
env['ANSIBLE_ASK_PASS'] = str(False)
env['ANSIBLE_ASK_SUDO_PASS'] = str(False)
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
@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
#

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)
@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", [
(None, 403),
('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
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['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'}]}
kwargs = obj._update_unified_job_kwargs(extra_vars={'my_variable':'$encrypted$'})
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
from awx.main.models import (
@ -14,3 +15,38 @@ def test_unified_job_workflow_attributes():
assert job.spawned_by_workflow is True
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 cStringIO
import json
import logging
from uuid import uuid4
from django.conf import settings
from django.conf import LazySettings
import pytest
import requests
@ -44,17 +46,27 @@ def http_adapter():
return FakeHTTPAdapter()
def test_https_logging_handler_requests_sync_implementation():
handler = HTTPSHandler(async=False)
assert not isinstance(handler.session, FuturesSession)
assert isinstance(handler.session, requests.Session)
@pytest.fixture()
def connection_error_adapter():
class ConnectionErrorAdapter(requests.adapters.HTTPAdapter):
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():
handler = HTTPSHandler(async=True)
handler = HTTPSHandler()
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())
def test_https_logging_handler_defaults(param):
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
@pytest.mark.parametrize('message_type, async', [
('logstash', False),
('logstash', True),
('splunk', False),
('splunk', True),
])
def test_https_logging_handler_connection_error(connection_error_adapter,
dummy_log_record):
handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True,
message_type='logstash',
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,
message_type, async):
message_type):
handler = HTTPSHandler(host='127.0.0.1', enabled_flag=True,
message_type=message_type,
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'],
async=async)
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'])
handler.setFormatter(LogstashFormatter())
handler.session.mount('http://', http_adapter)
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'
@pytest.mark.parametrize('async', (True, False))
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,
username='user', password='pass',
message_type='logstash',
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'],
async=async)
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'])
handler.setFormatter(LogstashFormatter())
handler.session.mount('http://', http_adapter)
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")
@pytest.mark.parametrize('async', (True, False))
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,
password='pass', message_type='splunk',
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'],
async=async)
enabled_loggers=['awx', 'activity_stream', 'job_events', 'system_tracking'])
handler.setFormatter(LogstashFormatter())
handler.session.mount('http://', http_adapter)
async_futures = handler.emit(dummy_log_record)

View File

@ -5,8 +5,10 @@
import logging
import json
import requests
from requests.exceptions import RequestException
import time
from concurrent.futures import ThreadPoolExecutor
from copy import copy
from requests.exceptions import RequestException
# loggly
import traceback
@ -19,6 +21,8 @@ from awx.main.utils.formatters import LogstashFormatter
__all__ = ['HTTPSNullHandler', 'BaseHTTPSHandler', 'configure_external_logger']
logger = logging.getLogger('awx.main.utils.handlers')
# AWX external logging handler, generally designed to be used
# with the accompanying LogstashHandler, derives from python-logstash library
# Non-blocking request accomplished by FuturesSession, similar
@ -34,6 +38,7 @@ PARAM_NAMES = {
'enabled_loggers': 'LOG_AGGREGATOR_LOGGERS',
'indv_facts': 'LOG_AGGREGATOR_INDIVIDUAL_FACTS',
'enabled_flag': 'LOG_AGGREGATOR_ENABLED',
'http_timeout': 'LOG_AGGREGATOR_HTTP_TIMEOUT',
}
@ -52,17 +57,41 @@ class HTTPSNullHandler(logging.NullHandler):
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):
def __init__(self, fqdn=False, **kwargs):
super(BaseHTTPSHandler, self).__init__()
self.fqdn = fqdn
self.async = kwargs.get('async', True)
for fd in PARAM_NAMES:
setattr(self, fd, kwargs.get(fd, None))
if self.async:
self.session = FuturesSession()
else:
self.session = requests.Session()
self.session = FuturesSession(executor=VerboseThreadPoolExecutor(
max_workers=2 # this is the default used by requests_futures
))
self.add_auth_information()
@classmethod
@ -135,10 +164,8 @@ class BaseHTTPSHandler(logging.Handler):
payload_str = json.dumps(payload_input)
else:
payload_str = payload_input
if self.async:
return dict(data=payload_str, background_callback=unused_callback)
else:
return dict(data=payload_str)
return dict(data=payload_str, background_callback=unused_callback,
timeout=self.http_timeout)
def skip_log(self, logger_name):
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
``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:
https://docs.python.org/3/library/concurrent.futures.html#future-objects
http://pythonhosted.org/futures/
@ -177,17 +200,10 @@ class BaseHTTPSHandler(logging.Handler):
for key in facts_dict:
fact_payload = copy(payload_data)
fact_payload.update(facts_dict[key])
if self.async:
async_futures.append(self._send(fact_payload))
else:
self._send(fact_payload)
async_futures.append(self._send(fact_payload))
return async_futures
if self.async:
return [self._send(payload)]
self._send(payload)
return []
return [self._send(payload)]
except (KeyboardInterrupt, SystemExit):
raise
except:
@ -209,7 +225,7 @@ def add_or_remove_logger(address, 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
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
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))
awx_logger_instance = instance
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
git:
dest: "{{project_path|quote}}"
repo: "{{scm_url|quote}}"
repo: "{{scm_url}}"
version: "{{scm_branch|quote}}"
force: "{{scm_clean}}"
accept_hostkey: "{{scm_accept_hostkey}}"
@ -42,7 +42,7 @@
- name: update project using git
git:
dest: "{{project_path|quote}}"
repo: "{{scm_url|quote}}"
repo: "{{scm_url}}"
version: "{{scm_branch|quote}}"
force: "{{scm_clean}}"
#clone: "{{ scm_full_checkout }}"
@ -160,6 +160,11 @@
scm_version: "{{scm_version|regex_replace('^.*Revision: ([0-9]+).*$', '\\1')}}"
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
debug: msg="Repository Version {{ scm_version }}"
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
# at the native registry location.
$packages = Get-ChildItem -Path $uninstall_native_path |
[PSObject []]$packages = Get-ChildItem -Path $uninstall_native_path |
Get-ItemProperty |
Select-Object -Property @{Name="name"; Expression={$_."DisplayName"}},
@{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
# 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 |
Select-Object -Property @{Name="name"; Expression={$_."DisplayName"}},
@{Name="version"; Expression={$_."DisplayVersion"}},

View File

@ -867,6 +867,7 @@ INSIGHTS_URL_BASE = "https://access.redhat.com"
TOWER_SETTINGS_MANIFEST = {}
LOG_AGGREGATOR_ENABLED = False
LOG_AGGREGATOR_HTTP_TIMEOUT = 5
# The number of retry attempts for websocket session establishment
# 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
scope.object = scope.resourceData.data;
// 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
// array w roles and descriptions for key

View File

@ -16,7 +16,6 @@ export default
roles: '=',
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>',
link: function(scope, element, attrs, ctrl) {
CreateSelect2({

View File

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

View File

@ -55,7 +55,8 @@ export default
dataTitle: i18n._('Organization') + ' ',
dataPlacement: 'bottom',
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: {
label: i18n._('Type'),

View File

@ -49,7 +49,8 @@ angular.module('InventoryFormDefinition', [])
reqExpression: "organizationrequired",
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: {
label: i18n._('Variables'),

View File

@ -349,7 +349,11 @@ export default
dataPlacement: 'right',
dataTitle: i18n._("Host Config Key"),
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: {
label: i18n._('Labels'),

View File

@ -42,7 +42,8 @@ export default
sourceModel: 'organization',
basePath: 'organizations',
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,
}
},

View File

@ -54,7 +54,8 @@ export default
dataContainer: 'body',
dataPlacement: 'right',
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: {
label: i18n._('Labels'),

View File

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

View File

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

View File

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

View File

@ -12,7 +12,7 @@
function adhocController($q, $scope, $location, $stateParams,
$state, CheckPasswords, PromptForPasswords, CreateLaunchDialog, CreateSelect2, adhocForm,
GenerateForm, Rest, ProcessErrors, ClearScope, GetBasePath, GetChoices,
KindChange, CredentialList, Empty, Wait) {
KindChange, CredentialList, ParseTypeChange, Empty, Wait) {
ClearScope();
@ -162,6 +162,12 @@ function adhocController($q, $scope, $location, $stateParams,
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(){
$state.go('inventoryManage');
};
@ -199,6 +205,7 @@ function adhocController($q, $scope, $location, $stateParams,
"module_args": "",
"forks": 0,
"verbosity": 0,
"extra_vars": "",
"privilege_escalation": ""
};
@ -297,5 +304,5 @@ function adhocController($q, $scope, $location, $stateParams,
export default ['$q', '$scope', '$location', '$stateParams',
'$state', 'CheckPasswords', 'PromptForPasswords', 'CreateLaunchDialog', 'CreateSelect2',
'adhocForm', 'GenerateForm', 'Rest', 'ProcessErrors', 'ClearScope', 'GetBasePath',
'GetChoices', 'KindChange', 'CredentialList', 'Empty', 'Wait',
'GetChoices', 'KindChange', 'CredentialList', 'ParseTypeChange', 'Empty', 'Wait',
adhocController];

View File

@ -10,7 +10,7 @@
* @description This form is for executing an adhoc command
*/
export default function() {
export default ['i18n', function(i18n) {
return {
addTitle: 'EXECUTE COMMAND',
name: 'adhoc',
@ -121,6 +121,23 @@ export default function() {
dataPlacement: 'right',
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: {
reset: {
@ -139,4 +156,4 @@ export default function() {
related: {}
};
}
}];

View File

@ -1,6 +1,6 @@
<div class="BreadCrumb InventoryManageBreadCrumbs">
<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">
<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>

View File

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

View File

@ -99,7 +99,7 @@
margin-bottom: 8px;
}
.HostEvent .modal-body{
max-height: 500px;
max-height: 600px;
padding: 0px!important;
overflow-y: auto;
}
@ -115,6 +115,7 @@
text-transform: uppercase;
flex: 0 1 80px;
max-width: 80px;
min-width: 80px;
font-size: 12px;
word-wrap: break-word;
}
@ -123,28 +124,10 @@
}
.HostEvent-field--content{
word-wrap: break-word;
max-width: 13em;
flex: 0 1 13em;
}
.HostEvent-field--monospaceContent{
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 {
pointer-events: all!important;
}

View File

@ -213,23 +213,8 @@ job-results-standard-out {
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 {
cursor: default;
}
.JobResults .CodeMirror-cursors {
display: none;
cursor: not-allowed;
}
.JobResults-downloadTooLarge {

View File

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

View File

@ -316,15 +316,6 @@ export default
$scope.revertToDefaultInventory = function() {
if($scope.has_default_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) {
$scope.selected_credential = angular.copy($scope.defaults.credential);
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>
</div>
<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>
<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 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>
</div>
<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>
<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 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>

View File

@ -7,13 +7,17 @@
import jobSubCredListController from './job-sub-cred-list.controller';
export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'CredentialList',
function(templateUrl, qs, GetBasePath, GenerateList, $compile, CredentialList) {
(templateUrl, qs, GetBasePath, GenerateList, $compile, CredentialList) => {
return {
scope: {},
scope: {
selectedCredential: '='
},
templateUrl: templateUrl('job-submission/lists/credential/job-sub-cred-list'),
controller: jobSubCredListController,
restrict: 'E',
link: function(scope) {
link: scope => {
let toDestroy = [];
scope.credential_default_params = {
order_by: 'name',
page_size: 5,
@ -28,11 +32,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
// Fire off the initial search
qs.search(GetBasePath('credentials'), scope.credential_default_params)
.then(function(res) {
.then(res => {
scope.credential_dataset = res.data;
scope.credentials = scope.credential_dataset.results;
var credList = _.cloneDeep(CredentialList);
let credList = _.cloneDeep(CredentialList);
let html = GenerateList.build({
list: credList,
input_type: 'radio',
@ -43,11 +47,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
$('#job-submission-credential-lookup').append($compile(html)(scope));
scope.$watchCollection('credentials', function () {
if(scope.selected_credential) {
toDestroy.push(scope.$watchCollection('selectedCredential', () => {
if(scope.selectedCredential) {
// Loop across the inventories and see if one of them should be "checked"
scope.credentials.forEach(function(row, i) {
if (row.id === scope.selected_credential.id) {
scope.credentials.forEach((row, i) => {
if (row.id === scope.selectedCredential.id) {
scope.credentials[i].checked = 1;
}
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';
export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$compile', 'InventoryList',
function(templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList) {
(templateUrl, qs, GetBasePath, GenerateList, $compile, InventoryList) => {
return {
scope: {},
scope: {
selectedInventory: '='
},
templateUrl: templateUrl('job-submission/lists/inventory/job-sub-inv-list'),
controller: jobSubInvListController,
restrict: 'E',
link: function(scope) {
link: scope => {
let toDestroy = [];
scope.inventory_default_params = {
order_by: 'name',
page_size: 5
@ -26,11 +30,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
// Fire off the initial search
qs.search(GetBasePath('inventory'), scope.inventory_default_params)
.then(function(res) {
.then(res => {
scope.inventory_dataset = res.data;
scope.inventories = scope.inventory_dataset.results;
var invList = _.cloneDeep(InventoryList);
let invList = _.cloneDeep(InventoryList);
let html = GenerateList.build({
list: invList,
input_type: 'radio',
@ -41,11 +45,11 @@ export default [ 'templateUrl', 'QuerySet', 'GetBasePath', 'generateList', '$com
$('#job-submission-inventory-lookup').append($compile(html)(scope));
scope.$watchCollection('inventories', function () {
if(scope.selected_inventory) {
toDestroy.push(scope.$watchCollection('selectedInventory', () => {
if(scope.selectedInventory) {
// Loop across the inventories and see if one of them should be "checked"
scope.inventories.forEach(function(row, i) {
if (row.id === scope.selected_inventory.id) {
scope.inventories.forEach((row, i) => {
if (row.id === scope.selectedInventory.id) {
scope.inventories[i].checked = 1;
}
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
['$state', '$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors', '$q',
['$state', '$rootScope', 'Rest', 'GetBasePath', 'ProcessErrors',
'ConfigService',
function($state, $rootScope, Rest, GetBasePath, ProcessErrors, $q,
function($state, $rootScope, Rest, GetBasePath, ProcessErrors,
ConfigService){
return {
get: function() {
@ -29,7 +29,7 @@ export default
msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status});
});
},
valid: function(license) {
if (!license.valid_key){
return false;

View File

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

View File

@ -49,7 +49,8 @@ export default ['i18n', 'NotificationsList', function(i18n, NotificationsList) {
required: true,
dataContainer: 'body',
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: {
label: i18n._('SCM Type'),
@ -134,6 +135,11 @@ export default ['i18n', 'NotificationsList', function(i18n, NotificationsList) {
search: {
kind: 'scm'
},
autopopulateLookup: false,
awRequiredWhen: {
reqExpression: "credRequired",
init: false
},
ngShow: "scm_type && scm_type.value !== 'manual'",
sourceModel: 'credential',
awLookupType: 'scm_credential',

View File

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

View File

@ -493,7 +493,8 @@ function(ConfigurationUtils, i18n, $rootScope) {
modelName = attrs.source,
lookupType = attrs.awlookuptype,
watcher = attrs.awRequiredWhen || undefined,
watchBasePath;
watchBasePath,
awLookupWhen = attrs.awLookupWhen;
if (attrs.autopopulatelookup !== undefined) {
autopopulateLookup = JSON.parse(attrs.autopopulatelookup);
@ -501,7 +502,6 @@ function(ConfigurationUtils, i18n, $rootScope) {
autopopulateLookup = true;
}
// The following block of code is for instances where the
// lookup field is reused by varying sub-forms. Example: The groups
// 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
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;
};

View File

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

View File

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

View File

@ -29,13 +29,14 @@ import config from './config/main';
import PromptDialog from './prompt-dialog';
import directives from './directives';
import features from './features/main';
import orgAdminLookup from './org-admin-lookup/main';
import 'angular-duration-format';
export default
angular.module('shared', [listGenerator.name,
formGenerator.name,
lookupModal.name,
smartSearch.name,
smartSearch.name,
paginate.name,
columnSort.name,
filters.name,
@ -55,6 +56,7 @@ angular.module('shared', [listGenerator.name,
directives.name,
filters.name,
features.name,
orgAdminLookup.name,
require('angular-cookies'),
'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,
ncyBreadcrumb: {
[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: {
'form': {
@ -386,14 +386,15 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto
function buildNotificationState(field) {
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({
searchPrefix: `${list.iterator}`,
name: `${formStateDefinition.name}.${list.iterator}s`,
url: `/${list.iterator}s`,
ncyBreadcrumb: {
parent: `${formStateDefinition.name}`,
label: `${field.iterator}s`
label: `${breadcrumbLabel}`
},
params: {
[list.iterator + '_search']: {
@ -581,14 +582,14 @@ export default ['$injector', '$stateExtender', '$log', 'i18n', function($injecto
list = field.include ? $injector.get(field.include) : field,
// Added this line specifically for Completed Jobs but should be OK
// for all the rest of the related tabs
breadcrumbLabel = field.iterator.replace('_', ' '),
breadcrumbLabel = (field.iterator.replace('_', ' ') + 's').toUpperCase(),
stateConfig = {
searchPrefix: `${list.iterator}`,
name: `${formStateDefinition.name}.${list.iterator}s`,
url: `/${list.iterator}s`,
ncyBreadcrumb: {
parent: `${formStateDefinition.name}`,
label: `${breadcrumbLabel}s`
label: `${breadcrumbLabel}`
},
params: {
[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-detailsContent col-lg-9 col-md-9 col-sm-9 col-xs-8">{{ verbosity }}</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>

View File

@ -149,7 +149,11 @@ export function JobStdoutController ($rootScope, $scope, $state, $stateParams,
}
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) {

View File

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

View File

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

View File

@ -8,12 +8,12 @@
[ '$scope', '$stateParams', 'WorkflowForm', 'GenerateForm', 'Alert', 'ProcessErrors',
'ClearScope', 'GetBasePath', '$q', 'ParseTypeChange', 'Wait', 'Empty',
'ToJSON', 'initSurvey', '$state', 'CreateSelect2', 'ParseVariableString',
'TemplatesService', 'OrganizationList', 'Rest', 'WorkflowService', 'ToggleNotification',
'TemplatesService', 'OrganizationList', 'Rest', 'WorkflowService', 'ToggleNotification', 'OrgAdminLookup',
function(
$scope, $stateParams, WorkflowForm, GenerateForm, Alert, ProcessErrors,
ClearScope, GetBasePath, $q, ParseTypeChange, Wait, Empty,
ToJSON, SurveyControllerInit, $state, CreateSelect2, ParseVariableString,
TemplatesService, OrganizationList, Rest, WorkflowService, ToggleNotification
TemplatesService, OrganizationList, Rest, WorkflowService, ToggleNotification, OrgAdminLookup
) {
ClearScope();
@ -145,6 +145,17 @@
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');
$scope.url = workflowJobTemplateData.url;
$scope.survey_enabled = workflowJobTemplateData.survey_enabled;

View File

@ -347,6 +347,20 @@ export default [ '$state','moment', '$timeout', '$window',
if(!d.isStartNode) {
let resourceName = (d.unifiedJobTemplate && d.unifiedJobTemplate.name) ? d.unifiedJobTemplate.name : "";
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
// it properly on the workflow
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;
}
.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 {
cursor: default;
}
.WorkflowResults .CodeMirror-cursors {
display: none;
cursor: not-allowed;
}

View File

@ -49,3 +49,5 @@ slackclient==1.0.2
twilio==5.6.0
uWSGI==2.0.14
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
# 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
secretstorage==2.3.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
# 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
COPY logstash.conf /
RUN touch /logstash.log
RUN chown logstash:logstash /logstash.log
CMD ["-f", "/logstash.conf"]

View File

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

View File

@ -1,11 +1,11 @@
---
version: '2'
version: '3'
services:
unit-tests:
build:
context: ../../../
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:
SWIG_FEATURES: "-cpperraswarn -includeall -I/usr/include/openssl"
TEST_DIRS: awx/main/tests/functional awx/main/tests/unit awx/conf/tests awx/sso/tests

View File

@ -56,6 +56,14 @@ deps =
commands =
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]
commands=
coverage combine