Merge branch 'release_3.1.2' into stable

* release_3.1.2: (149 commits)
  updating changelog for 3.1.2
  Add back SRC_ONLY_PKGS
  Fix ubuntu 14 restart service list pt 2
  Make sure the insight playbook fetch doesn't quote user and pass
  Add requirements/vendor to gitignore
  Conditionally install from local python dependencies in spec file
  Remove requirements/vendor on make clean.
  Update brew-srpm target to generate local requirements files
  Get offline pip installs working
  Add clean-dist target
  Navigate back to the jobDetails state when the user clicks outside the host event modal.
  Don't use jinja quote filter on insights username or password
  fix legacy standard out
  Fixed permissions typo
  add test, restore old behavior in api test
  create _survey_element_validation and use it for updating extra_vars
  Do not set the default if the field was not passed in to kwargs_extra_vars
  Remove log aggregator port required mark.
  Modify according to review feedback.
  Host Event json should be read-only
  ...
This commit is contained in:
Matthew Jones 2017-03-30 22:43:54 -04:00
commit 4ff732bc5e
165 changed files with 1713 additions and 729 deletions

1
.gitignore vendored
View File

@ -108,6 +108,7 @@ reports
*.results
local/
*.mo
requirements/vendor
# AWX python libs populated by requirements.txt
awx/lib/.deps_built

122
Makefile
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
@ -215,6 +215,7 @@ clean-bundle:
clean-ui:
rm -rf awx/ui/static/
rm -rf awx/ui/node_modules/
rm -rf awx/ui/coverage/
rm -f $(UI_DEPS_FLAG_FILE)
rm -f $(UI_RELEASE_FLAG_FILE)
@ -224,15 +225,18 @@ clean-tmp:
clean-venv:
rm -rf venv/
clean-dist:
rm -rf dist
# Remove temporary build files, compiled Python files.
clean: clean-rpm clean-deb clean-ui clean-tar clean-packer clean-bundle
clean: clean-rpm clean-deb clean-ui clean-tar clean-packer clean-bundle clean-dist
rm -rf awx/public
rm -rf awx/lib/site-packages
rm -rf dist/*
rm -rf awx/job_status
rm -rf awx/job_output
rm -rf reports
rm -f awx/awx_test.sqlite3
rm -rf requirements/vendor
rm -rf tmp
mkdir tmp
rm -rf build $(NAME)-$(VERSION) *.egg-info
@ -263,8 +267,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
@ -275,42 +279,40 @@ 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
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; \
if [[ "$(PIP_OPTIONS)" == *"--no-index"* ]]; then \
cat requirements/requirements_ansible.txt requirements/requirements_ansible_local.txt | $(VENV_BASE)/ansible/bin/pip install $(PIP_OPTIONS) --ignore-installed -r /dev/stdin ; \
else \
pip install --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements_ansible.txt ; \
pip uninstall --yes -r requirements/requirements_ansible_uninstall.txt; \
cat requirements/requirements_ansible.txt requirements/requirements_ansible_git.txt | $(VENV_BASE)/ansible/bin/pip install $(PIP_OPTIONS) --no-binary $(SRC_ONLY_PKGS) --ignore-installed -r /dev/stdin ; \
fi
$(VENV_BASE)/ansible/bin/pip uninstall --yes -r requirements/requirements_ansible_uninstall.txt
requirements_ansible_dev:
if [ "$(VENV_BASE)" ]; then \
$(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; \
if [[ "$(PIP_OPTIONS)" == *"--no-index"* ]]; then \
cat requirements/requirements.txt requirements/requirements_local.txt | $(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --ignore-installed -r /dev/stdin ; \
else \
pip install --ignore-installed --no-binary $(SRC_ONLY_PKGS) -r requirements/requirements.txt ; \
pip uninstall --yes -r requirements/requirements_tower_uninstall.txt; \
cat requirements/requirements.txt requirements/requirements_git.txt | $(VENV_BASE)/tower/bin/pip install $(PIP_OPTIONS) --no-binary $(SRC_ONLY_PKGS) --ignore-installed -r /dev/stdin ; \
fi
$(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
@ -481,7 +483,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; \
@ -493,6 +495,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 \
@ -608,7 +616,7 @@ ui-test-ci: $(UI_DEPS_FLAG_FILE)
testjs_ci:
echo "Update UI unittests later" #ui-test-ci
jshint:
jshint: $(UI_DEPS_FLAG_FILE)
$(NPM_BIN) run --prefix awx/ui jshint
ui-test-saucelabs: $(UI_DEPS_FLAG_FILE)
@ -693,24 +701,68 @@ setup_bundle_tarball: setup-bundle-build setup-bundle-build/$(OFFLINE_TAR_FILE)
rpm-build:
mkdir -p $@
rpm-build/$(SDIST_TAR_FILE): rpm-build dist/$(SDIST_TAR_FILE)
rpm-build/$(SDIST_TAR_FILE): rpm-build dist/$(SDIST_TAR_FILE) tar-build/$(SETUP_TAR_FILE)
cp packaging/rpm/$(NAME).spec rpm-build/
cp packaging/rpm/tower.te rpm-build/
cp packaging/rpm/tower.fc rpm-build/
cp packaging/rpm/$(NAME).sysconfig rpm-build/
cp packaging/remove_tower_source.py rpm-build/
cp packaging/bytecompile.sh rpm-build/
cp tar-build/$(SETUP_TAR_FILE) rpm-build/
if [ "$(OFFICIAL)" != "yes" ] ; then \
(cd dist/ && tar zxf $(SDIST_TAR_FILE)) ; \
(cd dist/ && mv $(NAME)-$(VERSION)-$(BUILD) $(NAME)-$(VERSION)) ; \
(cd dist/ && tar czf ../rpm-build/$(SDIST_TAR_FILE) $(NAME)-$(VERSION)) ; \
ln -sf $(SDIST_TAR_FILE) rpm-build/$(NAME)-$(VERSION).tar.gz ; \
(cd tar-build/ && tar zxf $(SETUP_TAR_FILE)) ; \
(cd tar-build/ && mv $(NAME)-setup-$(VERSION)-$(BUILD) $(NAME)-setup-$(VERSION)) ; \
(cd tar-build/ && tar czf ../rpm-build/$(SETUP_TAR_FILE) $(NAME)-setup-$(VERSION)) ; \
ln -sf $(SETUP_TAR_FILE) rpm-build/$(NAME)-setup-$(VERSION).tar.gz ; \
else \
cp -a dist/$(SDIST_TAR_FILE) rpm-build/ ; \
fi
rpmtar: sdist rpm-build/$(SDIST_TAR_FILE)
brewrpmtar: rpm-build/python-deps.tar.gz requirements/requirements_local.txt requirements/requirements_ansible_local.txt rpmtar
rpm-build/python-deps.tar.gz: requirements/vendor rpm-build
tar czf rpm-build/python-deps.tar.gz requirements/vendor
requirements/vendor:
cat requirements/requirements.txt requirements/requirements_git.txt | pip download \
--no-binary=:all: \
--requirement=/dev/stdin \
--dest=$@ \
--exists-action=i
cat requirements/requirements_ansible.txt requirements/requirements_ansible_git.txt | pip download \
--no-binary=:all: \
--requirement=/dev/stdin \
--dest=$@ \
--exists-action=i
pip download \
--no-binary=:all: \
--requirement=requirements/requirements_setup_requires.txt \
--dest=$@ \
--exists-action=i
requirements/requirements_local.txt:
@echo "This is going to take a while..."
pip download \
--requirement=requirements/requirements_git.txt \
--no-deps \
--exists-action=w \
--dest=requirements/vendor 2>/dev/null | sed -n 's/^\s*Saved\s*//p' > $@
requirements/requirements_ansible_local.txt:
pip download \
--requirement=requirements/requirements_ansible_git.txt \
--no-deps \
--exists-action=w \
--dest=requirements/vendor 2>/dev/null | sed -n 's/^\s*Saved\s*//p' > $@
rpm-build/$(RPM_NVR).src.rpm: /etc/mock/$(MOCK_CFG).cfg
$(MOCK_BIN) -r $(MOCK_CFG) --resultdir rpm-build --buildsrpm --spec rpm-build/$(NAME).spec --sources rpm-build \
--define "tower_version $(VERSION)" --define "tower_release $(RELEASE)" $(SCL_DEFINES)
@ -721,6 +773,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)
@ -829,7 +883,7 @@ amazon-ebs:
cd packaging/packer && $(PACKER) build -only $@ $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
# Vagrant box using virtualbox provider
vagrant-virtualbox: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box
vagrant-virtualbox: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box tar-build/$(SETUP_TAR_FILE)
packaging/packer/ansible-tower-$(VERSION)-virtualbox.box: packaging/packer/output-virtualbox-iso/centos-7.ovf
cd packaging/packer && $(PACKER) build -only virtualbox-ovf $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
@ -840,7 +894,7 @@ packaging/packer/output-virtualbox-iso/centos-7.ovf:
virtualbox-iso: packaging/packer/output-virtualbox-iso/centos-7.ovf
# Vagrant box using VMware provider
vagrant-vmware: packaging/packer/ansible-tower-$(VERSION)-vmware.box
vagrant-vmware: packaging/packer/ansible-tower-$(VERSION)-vmware.box tar-build/$(SETUP_TAR_FILE)
packaging/packer/output-vmware-iso/centos-7.vmx:
cd packaging/packer && $(PACKER) build -only vmware-iso packer-centos-7.json

View File

@ -5,7 +5,7 @@ import os
import sys
import warnings
__version__ = '3.1.1'
__version__ = '3.1.2'
__all__ = ['__version__']

View File

@ -3,6 +3,7 @@
# Python
import re
import json
# Django
from django.core.exceptions import FieldError, ValidationError
@ -291,7 +292,7 @@ class FieldLookupBackend(BaseFilterBackend):
except (FieldError, FieldDoesNotExist, ValueError, TypeError) as e:
raise ParseError(e.args[0])
except ValidationError as e:
raise ParseError(e.messages)
raise ParseError(json.dumps(e.messages, ensure_ascii=False))
class OrderByBackend(BaseFilterBackend):
@ -310,6 +311,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,
@ -332,3 +335,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)
@ -3503,6 +3504,7 @@ class BaseJobEventsList(SubListAPIView):
parent_model = None # Subclasses must define this attribute.
relationship = 'job_events'
view_name = _('Job Events List')
search_fields = ('stdout',)
def finalize_response(self, request, response, *args, **kwargs):
response['X-UI-Max-Events'] = settings.RECOMMENDED_MAX_EVENTS_DISPLAY_HEADER
@ -3664,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(['-v'])[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

@ -30,6 +30,8 @@ from ansible.plugins.callback.default import CallbackModule as DefaultCallbackMo
from .events import event_context
from .minimal import CallbackModule as MinimalCallbackModule
CENSORED = "the output has been hidden due to the fact that 'no_log: true' was specified for this result" # noqa
class BaseCallbackModule(CallbackBase):
'''
@ -55,22 +57,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 +71,13 @@ class BaseCallbackModule(CallbackBase):
else:
task = None
if event_data.get('res'):
if event_data['res'].get('_ansible_no_log', False):
event_data['res'] = {'censored': CENSORED}
for i, item in enumerate(event_data['res'].get('results', [])):
if event_data['res']['results'][i].get('_ansible_no_log', False):
event_data['res']['results'][i] = {'censored': CENSORED}
with event_context.display_lock:
try:
event_context.add_local(event=event, **event_data)
@ -132,7 +125,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):
@ -304,6 +299,12 @@ class BaseCallbackModule(CallbackBase):
def v2_runner_on_ok(self, result):
# FIXME: Display detailed results or not based on verbosity.
# strip environment vars from the job event; it already exists on the
# job and sensitive values are filtered there
if result._task.get_name() == 'setup':
result._result.get('ansible_facts', {}).pop('ansible_env', None)
event_data = dict(
host=result._host.get_name(),
remote_addr=result._host.address,

View File

@ -242,9 +242,11 @@ register(
field_class=fields.IntegerField,
allow_null=True,
label=_('Logging Aggregator Port'),
help_text=_('Port on Logging Aggregator to send logs to (if required).'),
help_text=_('Port on Logging Aggregator to send logs to (if required and not'
' provided in Logging Aggregator).'),
category=_('Logging'),
category_slug='logging',
required=False
)
register(
'LOG_AGGREGATOR_TYPE',

View File

@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0035_v310_remove_tower_settings'),
]
operations = [
migrations.AlterField(
model_name='project',
name='scm_type',
field=models.CharField(default=b'', choices=[(b'', 'Manual'), (b'git', 'Git'), (b'hg', 'Mercurial'), (b'svn', 'Subversion'), (b'insights', 'Red Hat Insights')], max_length=8, blank=True, help_text='Specifies the source control system used to store the project.', verbose_name='SCM Type'),
),
migrations.AlterField(
model_name='projectupdate',
name='scm_type',
field=models.CharField(default=b'', choices=[(b'', 'Manual'), (b'git', 'Git'), (b'hg', 'Mercurial'), (b'svn', 'Subversion'), (b'insights', 'Red Hat Insights')], max_length=8, blank=True, help_text='Specifies the source control system used to store the project.', verbose_name='SCM Type'),
),
]

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

@ -130,13 +130,18 @@ class SurveyJobTemplateMixin(models.Model):
for survey_element in self.survey_spec.get("spec", []):
default = survey_element.get('default')
variable_key = survey_element.get('variable')
if survey_element.get('type') == 'password':
if variable_key in kwargs_extra_vars and default:
kw_value = kwargs_extra_vars[variable_key]
if kw_value.startswith('$encrypted$') and kw_value != default:
kwargs_extra_vars[variable_key] = default
if default is not None:
extra_vars[variable_key] = default
data = {variable_key: default}
errors = self._survey_element_validation(survey_element, data)
if not errors:
extra_vars[variable_key] = default
# Overwrite job template extra vars with explicit job extra vars
# and add on job extra vars
@ -144,6 +149,65 @@ class SurveyJobTemplateMixin(models.Model):
kwargs['extra_vars'] = json.dumps(extra_vars)
return kwargs
def _survey_element_validation(self, survey_element, data):
errors = []
if survey_element['variable'] not in data and survey_element['required']:
errors.append("'%s' value missing" % survey_element['variable'])
elif survey_element['type'] in ["textarea", "text", "password"]:
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) not in (str, unicode):
errors.append("Value %s for '%s' expected to be a string." % (data[survey_element['variable']],
survey_element['variable']))
return errors
if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']):
errors.append("'%s' value %s is too small (length is %s must be at least %s)." %
(survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min']))
if 'max' in survey_element and survey_element['max'] not in ["", None] and len(data[survey_element['variable']]) > int(survey_element['max']):
errors.append("'%s' value %s is too large (must be no more than %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['max']))
elif survey_element['type'] == 'integer':
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) != int:
errors.append("Value %s for '%s' expected to be an integer." % (data[survey_element['variable']],
survey_element['variable']))
return errors
if 'min' in survey_element and survey_element['min'] not in ["", None] and survey_element['variable'] in data and \
data[survey_element['variable']] < int(survey_element['min']):
errors.append("'%s' value %s is too small (must be at least %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['min']))
if 'max' in survey_element and survey_element['max'] not in ["", None] and survey_element['variable'] in data and \
data[survey_element['variable']] > int(survey_element['max']):
errors.append("'%s' value %s is too large (must be no more than %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['max']))
elif survey_element['type'] == 'float':
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) not in (float, int):
errors.append("Value %s for '%s' expected to be a numeric type." % (data[survey_element['variable']],
survey_element['variable']))
return errors
if 'min' in survey_element and survey_element['min'] not in ["", None] and data[survey_element['variable']] < float(survey_element['min']):
errors.append("'%s' value %s is too small (must be at least %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['min']))
if 'max' in survey_element and survey_element['max'] not in ["", None] and data[survey_element['variable']] > float(survey_element['max']):
errors.append("'%s' value %s is too large (must be no more than %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['max']))
elif survey_element['type'] == 'multiselect':
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) != list:
errors.append("'%s' value is expected to be a list." % survey_element['variable'])
else:
for val in data[survey_element['variable']]:
if val not in survey_element['choices']:
errors.append("Value %s for '%s' expected to be one of %s." % (val, survey_element['variable'],
survey_element['choices']))
elif survey_element['type'] == 'multiplechoice':
if survey_element['variable'] in data:
if data[survey_element['variable']] not in survey_element['choices']:
errors.append("Value %s for '%s' expected to be one of %s." % (data[survey_element['variable']],
survey_element['variable'],
survey_element['choices']))
return errors
def survey_variable_validation(self, data):
errors = []
if not self.survey_enabled:
@ -153,62 +217,7 @@ class SurveyJobTemplateMixin(models.Model):
if 'description' not in self.survey_spec:
errors.append("'description' missing from survey spec.")
for survey_element in self.survey_spec.get("spec", []):
if survey_element['variable'] not in data and \
survey_element['required']:
errors.append("'%s' value missing" % survey_element['variable'])
elif survey_element['type'] in ["textarea", "text", "password"]:
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) not in (str, unicode):
errors.append("Value %s for '%s' expected to be a string." % (data[survey_element['variable']],
survey_element['variable']))
continue
if 'min' in survey_element and survey_element['min'] not in ["", None] and len(data[survey_element['variable']]) < int(survey_element['min']):
errors.append("'%s' value %s is too small (length is %s must be at least %s)." %
(survey_element['variable'], data[survey_element['variable']], len(data[survey_element['variable']]), survey_element['min']))
if 'max' in survey_element and survey_element['max'] not in ["", None] and len(data[survey_element['variable']]) > int(survey_element['max']):
errors.append("'%s' value %s is too large (must be no more than %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['max']))
elif survey_element['type'] == 'integer':
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) != int:
errors.append("Value %s for '%s' expected to be an integer." % (data[survey_element['variable']],
survey_element['variable']))
continue
if 'min' in survey_element and survey_element['min'] not in ["", None] and survey_element['variable'] in data and \
data[survey_element['variable']] < int(survey_element['min']):
errors.append("'%s' value %s is too small (must be at least %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['min']))
if 'max' in survey_element and survey_element['max'] not in ["", None] and survey_element['variable'] in data and \
data[survey_element['variable']] > int(survey_element['max']):
errors.append("'%s' value %s is too large (must be no more than %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['max']))
elif survey_element['type'] == 'float':
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) not in (float, int):
errors.append("Value %s for '%s' expected to be a numeric type." % (data[survey_element['variable']],
survey_element['variable']))
continue
if 'min' in survey_element and survey_element['min'] not in ["", None] and data[survey_element['variable']] < float(survey_element['min']):
errors.append("'%s' value %s is too small (must be at least %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['min']))
if 'max' in survey_element and survey_element['max'] not in ["", None] and data[survey_element['variable']] > float(survey_element['max']):
errors.append("'%s' value %s is too large (must be no more than %s)." %
(survey_element['variable'], data[survey_element['variable']], survey_element['max']))
elif survey_element['type'] == 'multiselect':
if survey_element['variable'] in data:
if type(data[survey_element['variable']]) != list:
errors.append("'%s' value is expected to be a list." % survey_element['variable'])
else:
for val in data[survey_element['variable']]:
if val not in survey_element['choices']:
errors.append("Value %s for '%s' expected to be one of %s." % (val, survey_element['variable'],
survey_element['choices']))
elif survey_element['type'] == 'multiplechoice':
if survey_element['variable'] in data:
if data[survey_element['variable']] not in survey_element['choices']:
errors.append("Value %s for '%s' expected to be one of %s." % (data[survey_element['variable']],
survey_element['variable'],
survey_element['choices']))
errors += self._survey_element_validation(survey_element, data)
return errors

View File

@ -43,6 +43,7 @@ class ProjectOptions(models.Model):
('git', _('Git')),
('hg', _('Mercurial')),
('svn', _('Subversion')),
('insights', _('Red Hat Insights')),
]
class Meta:
@ -120,6 +121,8 @@ class ProjectOptions(models.Model):
return self.scm_type or ''
def clean_scm_url(self):
if self.scm_type == 'insights':
self.scm_url = settings.INSIGHTS_URL_BASE
scm_url = unicode(self.scm_url or '')
if not self.scm_type:
return ''
@ -141,6 +144,8 @@ class ProjectOptions(models.Model):
if cred.kind != 'scm':
raise ValidationError(_("Credential kind must be 'scm'."))
try:
if self.scm_type == 'insights':
self.scm_url = settings.INSIGHTS_URL_BASE
scm_url = update_scm_url(self.scm_type, self.scm_url,
check_special_cases=False)
scm_url_parts = urlparse.urlsplit(scm_url)

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):
@ -471,24 +471,24 @@ class BaseTask(Task):
env['PROOT_TMP_DIR'] = settings.AWX_PROOT_BASE_PATH
return env
def build_safe_env(self, instance, **kwargs):
def build_safe_env(self, env, **kwargs):
'''
Build environment dictionary, hiding potentially sensitive information
such as passwords or keys.
'''
hidden_re = re.compile(r'API|TOKEN|KEY|SECRET|PASS', re.I)
urlpass_re = re.compile(r'^.*?://.?:(.*?)@.*?$')
env = self.build_env(instance, **kwargs)
for k,v in env.items():
urlpass_re = re.compile(r'^.*?://[^:]+:(.*?)@.*?$')
safe_env = dict(env)
for k,v in safe_env.items():
if k in ('REST_API_URL', 'AWS_ACCESS_KEY', 'AWS_ACCESS_KEY_ID'):
continue
elif k.startswith('ANSIBLE_') and not k.startswith('ANSIBLE_NET'):
continue
elif hidden_re.search(k):
env[k] = HIDDEN_PASSWORD
safe_env[k] = HIDDEN_PASSWORD
elif type(v) == str and urlpass_re.match(v):
env[k] = urlpass_re.sub(HIDDEN_PASSWORD, v)
return env
safe_env[k] = urlpass_re.sub(HIDDEN_PASSWORD, v)
return safe_env
def args2cmdline(self, *args):
return ' '.join([pipes.quote(a) for a in args])
@ -699,7 +699,7 @@ class BaseTask(Task):
output_replacements = self.build_output_replacements(instance, **kwargs)
cwd = self.build_cwd(instance, **kwargs)
env = self.build_env(instance, **kwargs)
safe_env = self.build_safe_env(instance, **kwargs)
safe_env = self.build_safe_env(env, **kwargs)
stdout_handle = self.get_stdout_handle(instance)
if self.should_use_proot(instance, **kwargs):
if not check_proot_installed():
@ -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.
@ -1189,6 +1190,9 @@ class RunProjectUpdate(BaseTask):
scm_username = False
elif scm_url_parts.scheme.endswith('ssh'):
scm_password = False
elif scm_type == 'insights':
extra_vars['scm_username'] = scm_username
extra_vars['scm_password'] = scm_password
scm_url = update_scm_url(scm_type, scm_url, scm_username,
scm_password, scp_format=True)
else:
@ -1218,6 +1222,7 @@ class RunProjectUpdate(BaseTask):
scm_branch = project_update.scm_branch or {'hg': 'tip'}.get(project_update.scm_type, 'HEAD')
extra_vars.update({
'project_path': project_update.get_project_path(check_if_exists=False),
'insights_url': settings.INSIGHTS_URL_BASE,
'scm_type': project_update.scm_type,
'scm_url': scm_url,
'scm_branch': scm_branch,
@ -1314,10 +1319,10 @@ class RunProjectUpdate(BaseTask):
lines = fd.readlines()
if lines:
p.scm_revision = lines[0].strip()
p.playbook_files = p.playbooks
p.save()
else:
logger.error("Could not find scm revision in check")
logger.info("Could not find scm revision in check")
p.playbook_files = p.playbooks
p.save()
try:
os.remove(self.revision_path)
except Exception, e:

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

@ -1,11 +1,13 @@
import os
import re
import pytest
from pip.operations import freeze
from django.conf import settings
@pytest.mark.skip(reason="This test needs some love")
def test_env_matches_requirements_txt():
def check_is_in(src, dests):
if src not in dests:

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

@ -4,6 +4,7 @@ import json
from awx.main.tasks import RunJob
from awx.main.models import (
Job,
JobTemplate,
WorkflowJobTemplate
)
@ -78,6 +79,18 @@ def test_job_args_unredacted_passwords(job):
assert extra_vars['secret_key'] == 'my_password'
def test_update_kwargs_survey_invalid_default(survey_spec_factory):
spec = survey_spec_factory('var2')
spec['spec'][0]['required'] = False
spec['spec'][0]['min'] = 3
spec['spec'][0]['default'] = 1
jt = JobTemplate(name="test-jt", survey_spec=spec, survey_enabled=True, extra_vars="var2: 2")
defaulted_extra_vars = jt._update_unified_job_kwargs()
assert 'extra_vars' in defaulted_extra_vars
# Make sure we did not set the invalid default of 1
assert json.loads(defaulted_extra_vars['extra_vars'])['var2'] == 2
class TestWorkflowSurveys:
def test_update_kwargs_survey_defaults(self, survey_spec_factory):
"Assure that the survey default over-rides a JT variable"

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

@ -71,6 +71,25 @@ def test_run_admin_checks_usage(mocker, current_instances, call_count):
assert 'expire' in mock_sm.call_args_list[0][0][0]
@pytest.mark.parametrize("key,value", [
('REST_API_TOKEN', 'SECRET'),
('SECRET_KEY', 'SECRET'),
('RABBITMQ_PASS', 'SECRET'),
('VMWARE_PASSWORD', 'SECRET'),
('API_SECRET', 'SECRET'),
('CALLBACK_CONNECTION', 'amqp://tower:password@localhost:5672/tower'),
])
def test_safe_env_filtering(key, value):
task = tasks.RunJob()
assert task.build_safe_env({key: value})[key] == tasks.HIDDEN_PASSWORD
def test_safe_env_returns_new_copy():
task = tasks.RunJob()
env = {'foo': 'bar'}
assert task.build_safe_env(env) is not env
def test_openstack_client_config_generation(mocker):
update = tasks.RunInventoryUpdate()
inventory_update = mocker.Mock(**{

View File

@ -1,7 +1,9 @@
import base64
import cStringIO
import json
import logging
from django.conf import settings
from django.conf import LazySettings
import pytest
import requests
@ -40,17 +42,27 @@ def ok200_adapter():
return OK200Adapter()
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()
@ -95,7 +107,17 @@ def test_https_logging_handler_splunk_auth_info():
('http://localhost', None, 'http://localhost'),
('http://localhost', 80, 'http://localhost'),
('http://localhost', 8080, 'http://localhost:8080'),
('https://localhost', 443, 'https://localhost:443')
('https://localhost', 443, 'https://localhost:443'),
('ftp://localhost', 443, 'ftp://localhost:443'),
('https://localhost:550', 443, 'https://localhost:550'),
('https://localhost:yoho/foobar', 443, 'https://localhost:443/foobar'),
('https://localhost:yoho/foobar', None, 'https://localhost:yoho/foobar'),
('http://splunk.server:8088/services/collector/event', 80,
'http://splunk.server:8088/services/collector/event'),
('http://splunk.server/services/collector/event', 80,
'http://splunk.server/services/collector/event'),
('http://splunk.server/services/collector/event', 8088,
'http://splunk.server:8088/services/collector/event'),
])
def test_https_logging_handler_http_host_format(host, port, normalized):
handler = HTTPSHandler(host=host, port=port)
@ -114,18 +136,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(ok200_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://', ok200_adapter)
async_futures = handler.emit(dummy_log_record)
@ -151,14 +194,12 @@ def test_https_logging_handler_emit(ok200_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(ok200_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://', ok200_adapter)
async_futures = handler.emit(dummy_log_record)
@ -169,13 +210,11 @@ def test_https_logging_handler_emit_logstash_with_creds(ok200_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(ok200_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://', ok200_adapter)
async_futures = handler.emit(dummy_log_record)

View File

@ -261,7 +261,7 @@ def update_scm_url(scm_type, url, username=True, password=True,
# git: https://www.kernel.org/pub/software/scm/git/docs/git-clone.html#URLS
# hg: http://www.selenic.com/mercurial/hg.1.html#url-paths
# svn: http://svnbook.red-bean.com/en/1.7/svn-book.html#svn.advanced.reposurls
if scm_type not in ('git', 'hg', 'svn'):
if scm_type not in ('git', 'hg', 'svn', 'insights'):
raise ValueError(_('Unsupported SCM type "%s"') % str(scm_type))
if not url.strip():
return ''
@ -307,6 +307,7 @@ def update_scm_url(scm_type, url, username=True, password=True,
'git': ('ssh', 'git', 'git+ssh', 'http', 'https', 'ftp', 'ftps', 'file'),
'hg': ('http', 'https', 'ssh', 'file'),
'svn': ('http', 'https', 'svn', 'svn+ssh', 'file'),
'insights': ('http', 'https')
}
if parts.scheme not in scm_type_schemes.get(scm_type, ()):
raise ValueError(_('Unsupported %s URL') % scm_type)
@ -342,7 +343,7 @@ def update_scm_url(scm_type, url, username=True, password=True,
#raise ValueError('Password not supported for SSH with Mercurial.')
netloc_password = ''
if netloc_username and parts.scheme != 'file':
if netloc_username and parts.scheme != 'file' and scm_type != "insights":
netloc = u':'.join([urllib.quote(x) for x in (netloc_username, netloc_password) if x])
else:
netloc = u''

View File

@ -13,7 +13,9 @@ class LogstashFormatter(LogstashFormatterVersion1):
ret = super(LogstashFormatter, self).__init__(**kwargs)
if settings_module:
self.host_id = settings_module.CLUSTER_HOST_ID
self.tower_uuid = settings_module.LOG_AGGREGATOR_TOWER_UUID
if hasattr(settings_module, 'LOG_AGGREGATOR_TOWER_UUID'):
self.tower_uuid = settings_module.LOG_AGGREGATOR_TOWER_UUID
self.message_type = settings_module.LOG_AGGREGATOR_TYPE
return ret
def reformat_data_for_log(self, raw_data, kind=None):

View File

@ -5,6 +5,9 @@
import logging
import json
import requests
import time
import urlparse
from concurrent.futures import ThreadPoolExecutor
from copy import copy
# loggly
@ -18,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
@ -33,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',
}
@ -47,17 +53,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
@ -89,10 +119,21 @@ class BaseHTTPSHandler(logging.Handler):
def get_http_host(self):
host = self.host or ''
if not host.startswith('http'):
host = 'http://%s' % self.host
if self.port != 80 and self.port is not None:
host = '%s:%s' % (host, str(self.port))
# urlparse requires scheme to be provided, default to use http if
# missing
if not urlparse.urlsplit(host).scheme:
host = 'http://%s' % host
parsed = urlparse.urlsplit(host)
# Insert self.port if its special and port number is either not
# given in host or given as non-numerical
try:
port = parsed.port or self.port
except ValueError:
port = self.port
if port not in (80, None):
new_netloc = '%s:%s' % (parsed.hostname, port)
return urlparse.urlunsplit((parsed.scheme, new_netloc, parsed.path,
parsed.query, parsed.fragment))
return host
def get_post_kwargs(self, payload_input):
@ -105,10 +146,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):
@ -123,10 +162,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/
@ -147,17 +182,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:
@ -179,7 +207,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):
@ -188,7 +216,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 }}"
@ -105,6 +105,45 @@
scm_version: "{{ scm_result['after'] }}"
when: "'after' in scm_result"
- name: update project using insights
uri:
url: "{{insights_url}}/r/insights/v1/maintenance?ansible=true"
user: "{{scm_username}}"
password: "{{scm_password}}"
force_basic_auth: yes
when: scm_type == 'insights'
register: insights_output
- name: Ensure the project directory is present
file:
dest: "{{project_path|quote}}"
state: directory
when: scm_type == 'insights'
- name: Fetch Insights Playbook With Name
get_url:
url: "{{insights_url}}/r/insights/v3/maintenance/{{item.maintenance_id}}/playbook"
dest: "{{project_path|quote}}/{{item.name}}-{{item.maintenance_id}}.yml"
url_username: "{{scm_username}}"
url_password: "{{scm_password}}"
force_basic_auth: yes
force: yes
when: scm_type == 'insights' and item.name != None
with_items: "{{insights_output.json}}"
failed_when: false
- name: Fetch Insights Playbook
get_url:
url: "{{insights_url}}/r/insights/v3/maintenance/{{item.maintenance_id}}/playbook"
dest: "{{project_path|quote}}/insights-plan-{{item.maintenance_id}}.yml"
url_username: "{{scm_username}}"
url_password: "{{scm_password}}"
force_basic_auth: yes
force: yes
when: scm_type == 'insights' and item.name == None
with_items: "{{insights_output.json}}"
failed_when: false
- name: detect requirements.yml
stat: path={{project_path|quote}}/roles/requirements.yml
register: doesRequirementsExist
@ -121,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

@ -862,9 +862,12 @@ TOWER_ADMIN_ALERTS = True
# Note: This setting may be overridden by database settings.
TOWER_URL_BASE = "https://towerhost"
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

@ -377,9 +377,9 @@ register(
help_text=_('User profile flags updated from group membership (key is user '
'attribute name, value is group DN). These are boolean fields '
'that are matched based on whether the user is a member of the '
'given group. So far only is_superuser is settable via this '
'method. This flag is set both true and false at login time '
'based on current LDAP settings.'),
'given group. So far only is_superuser and is_system_auditor '
'are settable via this method. This flag is set both true and '
'false at login time based on current LDAP settings.'),
category=_('LDAP'),
category_slug='ldap',
placeholder=collections.OrderedDict([

View File

@ -322,7 +322,7 @@ class LDAPUserFlagsField(fields.DictField):
default_error_messages = {
'invalid_flag': _('Invalid user flag: "{invalid_flag}".'),
}
valid_user_flags = {'is_superuser'}
valid_user_flags = {'is_superuser', 'is_system_auditor'}
child = LDAPDNField()
def to_internal_value(self, data):

View File

@ -40,7 +40,6 @@
.Form-title{
flex: 0 1 auto;
text-transform: uppercase;
color: @list-header-txt;
font-size: 14px;
font-weight: bold;
@ -50,6 +49,10 @@
margin-bottom: 20px;
}
.Form-title--uppercase {
text-transform: uppercase;
}
.Form-secondaryTitle{
color: @default-icon;
padding-bottom: 20px;
@ -98,8 +101,8 @@
.Form-tabHolder{
display: flex;
margin-bottom: 20px;
min-height: 30px;
flex-wrap:wrap;
}
.Form-tabs {
@ -115,6 +118,7 @@
height: 30px;
border-radius: 5px;
margin-right: 20px;
margin-bottom: 20px;
padding-left: 10px;
padding-right: 10px;
padding-bottom: 5px;
@ -560,6 +564,8 @@ input[type='radio']:checked:before {
padding-left:15px;
padding-right: 15px;
margin-right: 20px;
min-height: 30px;
margin-bottom: 20px;
}
.Form-primaryButton:hover {

View File

@ -147,7 +147,6 @@ table, tbody {
font-size: 14px;
font-weight: bold;
margin-right: 10px;
text-transform: uppercase;
}
.List-actionHolder {

View File

@ -1,47 +1,60 @@
/** @define About */
@import "./client/src/shared/branding/colors.default.less";
.About-cowsay--container{
width: 340px;
margin: 0 auto;
.About-ansibleVersion,
.About-cowsayCode {
font-family: Monaco, Menlo, Consolas, "Courier New", monospace;
}
.About-cowsay--code{
background-color: @default-bg;
padding-left: 30px;
border-style: none;
max-width: 340px;
padding-left: 30px;
.About-cowsayContainer {
width: 340px;
margin: 0 auto;
}
.About .modal-header{
border: none;
padding-bottom: 0px;
.About-cowsayCode {
background-color: @default-bg;
padding-left: 30px;
border-style: none;
max-width: 340px;
padding-left: 30px;
}
.About .modal-dialog{
max-width: 500px;
.About-modalHeader {
border: none;
padding-bottom: 0px;
}
.About .modal-body{
padding-top: 0px;
.About-modalDialog {
max-width: 500px;
}
.About-brand--redhat{
.About-modalBody {
padding-top: 0px;
padding-bottom: 0px;
}
.About-brandImg {
float: left;
width: 112px;
padding-top: 13px;
}
.About-brand--ansible{
max-width: 120px;
margin: 0 auto;
.About-close {
position: absolute;
top: 15px;
right: 15px;
z-index: 10;
}
.About-close{
position: absolute;
top: 15px;
right: 15px;
z-index: 10;
.About-modalFooter {
clear: both;
}
.About p{
color: @default-interface-txt;
.About-footerText {
text-align: right;
color: @default-interface-txt;
margin: 0;
font-size: 12px;
padding-top: 10px;
}
.About-modal--footer {
clear: both;
.About-ansibleVersion {
color: @default-data-txt;
}

View File

@ -1,27 +1,12 @@
export default
['$scope', '$state', 'ConfigService', 'i18n',
function($scope, $state, ConfigService, i18n){
var processVersion = function(version){
// prettify version & calculate padding
// e,g 3.0.0-0.git201602191743/ -> 3.0.0
var split = version.split('-')[0];
var spaces = Math.floor((16-split.length)/2),
paddedStr = "";
for(var i=0; i<=spaces; i++){
paddedStr = paddedStr +" ";
}
paddedStr = paddedStr + split;
for(var j = paddedStr.length; j<16; j++){
paddedStr = paddedStr + " ";
}
return paddedStr;
};
['$scope', '$state', 'ConfigService',
function($scope, $state, ConfigService){
var init = function(){
ConfigService.getConfig()
.then(function(config){
$scope.version = config.version.split('-')[0];
$scope.ansible_version = config.ansible_version;
$scope.subscription = config.license_info.subscription_name;
$scope.version = processVersion(config.version);
$scope.version_str = i18n._("Version");
$('#about-modal').modal('show');
});
};

View File

@ -1,19 +1,18 @@
<div class="About modal fade" id="about-modal">
<div class="modal-dialog">
<div class="modal-dialog About-modalDialog">
<div class="modal-content">
<div class="modal-header">
<div class="modal-header About-modalHeader">
<button data-dismiss="modal" type="button" class="close About-close">
<span class="fa fa-times-circle"></span>
</button>
</div>
<div class="modal-body">
<div class="About-cowsay--container">
<div class="modal-body About-modalBody">
<div class="About-cowsayContainer">
<!-- Don't indent this properly, you'll break the cow -->
<pre class="About-cowsay--code">
________________
/ Tower {{version_str}} \
\<span>{{version}}</span>/
----------------
<pre class="About-cowsayCode">
_______________
< Tower {{version}} >
---------------
\ ^__^
\ (oo)\_______
(__) A )\/\
@ -21,10 +20,15 @@
|| ||
</pre>
</div>
<div class="About-modal--footer">
<img class="About-brand--redhat img-responsive" src="/static/assets/tower-logo-login.svg" />
<p class="text-right">Copyright &copy; 2017 Red Hat, Inc. <br>
Visit <a href="http://www.ansible.com/" target="_blank">Ansible.com</a> for more information.<br>
<div class="About-modalFooter">
<img class="About-brandImg img-responsive" src="/static/assets/tower-logo-login.svg" />
<p class="About-footerText">
<span class="About-ansibleVersion">
Ansible {{ ansible_version }}
</span> <br>
Copyright &copy; 2017 Red Hat, Inc. <br>
Visit <a href="http://www.ansible.com/" target="_blank">Ansible.com</a> for more information.<br>
</p>
</div>
</div>
</div>

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

@ -65,7 +65,6 @@
.BreadCrumb-item {
display: inline-block;
color: @default-interface-txt;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@ -36,19 +36,11 @@
.Form-nav--dropdownContainer {
width: 285px;
margin-top: -52px;
margin-bottom: 22px;
margin-left: auto;
display: flex;
justify-content: space-between;
}
@media (max-width: 900px) {
.Form-nav--dropdownContainer {
margin: 0;
}
}
.Form-nav--dropdown {
width: 60%;
}

View File

@ -7,7 +7,7 @@
<div class="tab-pane" id="configuration-panel">
<div ng-cloak id="htmlTemplate" class="Panel">
<div class="Form-header">
<div class="Form-title" translate>Configure Tower</div>
<div class="Form-title" translate>CONFIGURE TOWER</div>
</div>
<div class="row Form-tabRow">
<div class="col-lg-12">

View File

@ -27,7 +27,7 @@
},
ncyBreadcrumb: {
parent: 'setup',
label: N_("Edit Configuration")
label: N_("EDIT CONFIGURATION")
},
controller: ConfigurationController,
resolve: {

View File

@ -126,6 +126,7 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log,
init();
function init() {
$scope.canEditOrg = true;
// Load the list of options for Kind
GetChoices({
scope: $scope,
@ -288,7 +289,7 @@ CredentialsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location',
export function CredentialsEdit($scope, $rootScope, $compile, $location, $log,
$stateParams, CredentialForm, Rest, Alert, ProcessErrors, ClearScope, Prompt,
GetBasePath, GetChoices, KindChange, BecomeMethodChange, Empty, OwnerChange, FormSave, Wait,
$state, CreateSelect2, Authorization, i18n) {
$state, CreateSelect2, Authorization, i18n, OrgAdminLookup) {
ClearScope();
@ -499,6 +500,16 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log,
setAskCheckboxes();
if(data.organization) {
OrgAdminLookup.checkForAdminAccess({organization: data.organization})
.then(function(canEditOrg){
$scope.canEditOrg = canEditOrg;
});
}
else {
$scope.canEditOrg = true;
}
$scope.$emit('credentialLoaded');
Wait('stop');
})
@ -626,5 +637,5 @@ CredentialsEdit.$inject = ['$scope', '$rootScope', '$compile', '$location',
'$log', '$stateParams', 'CredentialForm', 'Rest', 'Alert',
'ProcessErrors', 'ClearScope', 'Prompt', 'GetBasePath', 'GetChoices',
'KindChange', 'BecomeMethodChange', 'Empty', 'OwnerChange',
'FormSave', 'Wait', '$state', 'CreateSelect2', 'Authorization', 'i18n',
'FormSave', 'Wait', '$state', 'CreateSelect2', 'Authorization', 'i18n', 'OrgAdminLookup'
];

View File

@ -97,7 +97,7 @@ export function ProjectsList($scope, $rootScope, $location, $log, $stateParams,
$scope.reloadList = function(){
let path = GetBasePath(list.basePath) || GetBasePath(list.name);
qs.search(path, $stateParams[`${list.iterator}_search`])
qs.search(path, $state.params[`${list.iterator}_search`])
.then(function(searchResponse) {
$scope[`${list.iterator}_dataset`] = searchResponse.data;
$scope[list.name] = $scope[`${list.iterator}_dataset`].results;
@ -314,6 +314,7 @@ export function ProjectsAdd($scope, $rootScope, $compile, $location, $log,
init();
function init() {
$scope.canEditOrg = true;
Rest.setUrl(GetBasePath('projects'));
Rest.options()
.success(function(data) {
@ -348,6 +349,7 @@ export function ProjectsAdd($scope, $rootScope, $compile, $location, $log,
});
$scope.scmRequired = false;
$scope.credRequired = false;
master.scm_type = $scope.scm_type;
});
@ -408,6 +410,7 @@ export function ProjectsAdd($scope, $rootScope, $compile, $location, $log,
if ($scope.scm_type) {
$scope.pathRequired = ($scope.scm_type.value === 'manual') ? true : false;
$scope.scmRequired = ($scope.scm_type.value !== 'manual') ? true : false;
$scope.credRequired = ($scope.scm_type.value === 'insights') ? true : false;
$scope.scmBranchLabel = ($scope.scm_type.value === 'svn') ? 'Revision #' : 'SCM Branch';
}
@ -415,6 +418,7 @@ export function ProjectsAdd($scope, $rootScope, $compile, $location, $log,
if ($scope.scm_type.value) {
switch ($scope.scm_type.value) {
case 'git':
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p>' +
i18n._('Example URLs for GIT SCM include:') +
'</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' +
@ -424,11 +428,13 @@ export function ProjectsAdd($scope, $rootScope, $compile, $location, $log,
'SSH. GIT read only protocol (git://) does not use username or password information.'), '<strong>', '</strong>');
break;
case 'svn':
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' +
'<li>svn+ssh://servername.example.com/path</li></ul>';
break;
case 'hg':
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' +
'<li>ssh://server.example.com/path</li></ul>' +
@ -436,7 +442,14 @@ export function ProjectsAdd($scope, $rootScope, $compile, $location, $log,
'Do not put the username and key in the URL. ' +
'If using Bitbucket and SSH, do not supply your Bitbucket username.'), '<strong>', '</strong>');
break;
case 'insights':
$scope.pathRequired = false;
$scope.scmRequired = false;
$scope.credRequired = true;
$scope.credentialLabel = "Credential";
break;
default:
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p> ' + i18n._('URL popover text');
}
}
@ -455,7 +468,7 @@ ProjectsAdd.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log',
export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
$stateParams, ProjectsForm, Rest, Alert, ProcessErrors, GenerateForm,
Prompt, ClearScope, GetBasePath, GetProjectPath, Authorization,
GetChoices, Empty, DebugForm, Wait, ProjectUpdate, $state, CreateSelect2, ToggleNotification, i18n) {
GetChoices, Empty, DebugForm, Wait, ProjectUpdate, $state, CreateSelect2, ToggleNotification, i18n, OrgAdminLookup) {
ClearScope('htmlTemplate');
@ -509,6 +522,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
$scope.pathRequired = ($scope.scm_type.value === 'manual') ? true : false;
$scope.scmRequired = ($scope.scm_type.value !== 'manual') ? true : false;
$scope.credRequired = ($scope.scm_type.value === 'insights') ? true : false;
$scope.scmBranchLabel = ($scope.scm_type.value === 'svn') ? 'Revision #' : 'SCM Branch';
Wait('stop');
@ -586,6 +600,11 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
$scope.scm_type_class = "btn-disabled";
}
OrgAdminLookup.checkForAdminAccess({organization: data.organization})
.then(function(canEditOrg){
$scope.canEditOrg = canEditOrg;
});
$scope.project_obj = data;
$scope.name = data.name;
$scope.$emit('projectLoaded');
@ -692,6 +711,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
if ($scope.scm_type) {
$scope.pathRequired = ($scope.scm_type.value === 'manual') ? true : false;
$scope.scmRequired = ($scope.scm_type.value !== 'manual') ? true : false;
$scope.credRequired = ($scope.scm_type.value === 'insights') ? true : false;
$scope.scmBranchLabel = ($scope.scm_type.value === 'svn') ? i18n._('Revision #') : i18n._('SCM Branch');
}
@ -699,6 +719,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
if ($scope.scm_type.value) {
switch ($scope.scm_type.value) {
case 'git':
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p>' + i18n._('Example URLs for GIT SCM include:') + '</p><ul class=\"no-bullets\"><li>https://github.com/ansible/ansible.git</li>' +
'<li>git@github.com:ansible/ansible.git</li><li>git://servername.example.com/ansible.git</li></ul>' +
'<p>' + i18n.sprintf(i18n._('%sNote:%s When using SSH protocol for GitHub or Bitbucket, enter an SSH key only, ' +
@ -706,11 +727,13 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
'SSH. GIT read only protocol (git://) does not use username or password information.'), '<strong>', '</strong>');
break;
case 'svn':
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p>' + i18n._('Example URLs for Subversion SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://github.com/ansible/ansible</li><li>svn://servername.example.com/path</li>' +
'<li>svn+ssh://servername.example.com/path</li></ul>';
break;
case 'hg':
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p>' + i18n._('Example URLs for Mercurial SCM include:') + '</p>' +
'<ul class=\"no-bullets\"><li>https://bitbucket.org/username/project</li><li>ssh://hg@bitbucket.org/username/project</li>' +
'<li>ssh://server.example.com/path</li></ul>' +
@ -718,7 +741,14 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
'Do not put the username and key in the URL. ' +
'If using Bitbucket and SSH, do not supply your Bitbucket username.'), '<strong>', '</strong>');
break;
case 'insights':
$scope.pathRequired = false;
$scope.scmRequired = false;
$scope.credRequired = true;
$scope.credentialLabel = "Credential";
break;
default:
$scope.credentialLabel = "SCM Credential";
$scope.urlPopover = '<p> ' + i18n._('URL popover text');
}
}
@ -742,4 +772,4 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
ProjectsEdit.$inject = ['$scope', '$rootScope', '$compile', '$location', '$log',
'$stateParams', 'ProjectsForm', 'Rest', 'Alert', 'ProcessErrors', 'GenerateForm',
'Prompt', 'ClearScope', 'GetBasePath', 'GetProjectPath', 'Authorization', 'GetChoices', 'Empty',
'DebugForm', 'Wait', 'ProjectUpdate', '$state', 'CreateSelect2', 'ToggleNotification', 'i18n'];
'DebugForm', 'Wait', 'ProjectUpdate', '$state', 'CreateSelect2', 'ToggleNotification', 'i18n', 'OrgAdminLookup'];

View File

@ -110,6 +110,7 @@ export function TeamsAdd($scope, $rootScope, $stateParams, TeamForm, GenerateFor
init();
function init() {
$scope.canEditOrg = true;
// apply form definition's default field values
GenerateForm.applyDefaults(form, $scope);
@ -154,7 +155,7 @@ TeamsAdd.$inject = ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Generat
export function TeamsEdit($scope, $rootScope, $stateParams,
TeamForm, Rest, ProcessErrors, ClearScope, GetBasePath, Wait, $state) {
TeamForm, Rest, ProcessErrors, ClearScope, GetBasePath, Wait, $state, OrgAdminLookup) {
ClearScope();
@ -172,6 +173,11 @@ export function TeamsEdit($scope, $rootScope, $stateParams,
setScopeFields(data);
$scope.organization_name = data.summary_fields.organization.name;
OrgAdminLookup.checkForAdminAccess({organization: data.organization})
.then(function(canEditOrg){
$scope.canEditOrg = canEditOrg;
});
$scope.team_obj = data;
Wait('stop');
});
@ -243,5 +249,5 @@ export function TeamsEdit($scope, $rootScope, $stateParams,
}
TeamsEdit.$inject = ['$scope', '$rootScope', '$stateParams', 'TeamForm', 'Rest',
'ProcessErrors', 'ClearScope', 'GetBasePath', 'Wait', '$state'
'ProcessErrors', 'ClearScope', 'GetBasePath', 'Wait', '$state', 'OrgAdminLookup'
];

View File

@ -10,8 +10,8 @@ export default [ 'i18n', function(i18n){
name: 'hosts',
iterator: 'host',
selectTitle: i18n._('Add Existing Hosts'),
editTitle: i18n._('Hosts'),
listTitle: i18n._('Hosts'),
editTitle: i18n._('HOSTS'),
listTitle: i18n._('HOSTS'),
index: false,
hover: true,
well: true,

View File

@ -22,7 +22,7 @@ export default
return {
name: 'activity',
editTitle: i18n._('Activity Detail'),
editTitle: i18n._('ACTIVITY DETAIL'),
well: false,
'class': 'horizontal-narrow',
formFieldSize: 'col-lg-10',

View File

@ -15,7 +15,7 @@ export default
.factory('CredentialForm', ['i18n', function(i18n) {
return {
addTitle: i18n._('Create Credential'), //Legend in add mode
addTitle: i18n._('CREATE CREDENTIAL'), //Legend in add mode
editTitle: '{{ name }}', //Legend in edit mode
name: 'credential',
// the top-most node of generated state tree
@ -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

@ -14,7 +14,7 @@ export default
angular.module('GroupFormDefinition', [])
.value('GroupFormObject', {
addTitle: 'Create Group',
addTitle: 'CREATE GROUP',
editTitle: '{{ name }}',
showTitle: true,
name: 'group',

View File

@ -14,7 +14,7 @@ export default
angular.module('HostGroupsFormDefinition', [])
.value('HostGroupsForm', {
editTitle: 'Host Groups',
editTitle: 'HOST GROUPS',
name: 'host',
well: false,
formLabelSize: 'col-lg-3',

View File

@ -15,7 +15,7 @@ export default
.factory('HostForm', ['i18n', function(i18n) {
return {
addTitle: i18n._('Create Host'),
addTitle: i18n._('CREATE HOST'),
editTitle: '{{ host.name }}',
name: 'host',
basePath: 'hosts',

View File

@ -15,7 +15,7 @@ angular.module('InventoryFormDefinition', [])
.factory('InventoryForm', ['i18n', function(i18n) {
return {
addTitle: i18n._('New Inventory'),
addTitle: i18n._('NEW INVENTORY'),
editTitle: '{{ inventory_name }}',
name: 'inventory',
basePath: 'inventory',
@ -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

@ -3,7 +3,7 @@
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name forms.function:InventoryStatus
@ -14,7 +14,7 @@ export default
.value('InventoryStatusForm', {
name: 'inventory_update',
editTitle: 'Inventory Status',
editTitle: 'INVENTORY STATUS',
well: false,
'class': 'horizontal-narrow',

View File

@ -17,7 +17,7 @@ export default
.factory('JobTemplateFormObject', ['i18n', function(i18n) {
return {
addTitle: i18n._('New Job Template'),
addTitle: i18n._('NEW JOB TEMPLATE'),
editTitle: '{{ name }}',
name: 'job_template',
breadcrumbName: i18n._('JOB TEMPLATE'),
@ -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

@ -15,7 +15,7 @@ export default
.factory('OrganizationFormObject', ['i18n', function(i18n) {
return {
addTitle: i18n._('New Organization'), //Title in add mode
addTitle: i18n._('NEW ORGANIZATION'), //Title in add mode
editTitle: '{{ name }}', //Title in edit mode
name: 'organization', //entity or model name in singular form
stateTree: 'organizations',

View File

@ -3,7 +3,7 @@
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name forms.function:ProjectStatus
@ -15,7 +15,7 @@ export default
.value('ProjectStatusForm', {
name: 'project_update',
editTitle: 'SCM Status',
editTitle: 'SCM STATUS',
well: false,
'class': 'horizontal-narrow',

View File

@ -15,7 +15,7 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition'])
.factory('ProjectsFormObject', ['i18n', function(i18n) {
return {
addTitle: i18n._('New Project'),
addTitle: i18n._('NEW PROJECT'),
editTitle: '{{ name }}',
name: 'project',
basePath: 'projects',
@ -50,7 +50,8 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition'])
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'),
@ -105,7 +106,7 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition'])
scm_url: {
label: 'SCM URL',
type: 'text',
ngShow: "scm_type && scm_type.value !== 'manual'",
ngShow: "scm_type && scm_type.value !== 'manual' && scm_type.value !== 'insights' ",
awRequiredWhen: {
reqExpression: "scmRequired",
init: false
@ -122,12 +123,12 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition'])
scm_branch: {
labelBind: "scmBranchLabel",
type: 'text',
ngShow: "scm_type && scm_type.value !== 'manual'",
ngShow: "scm_type && scm_type.value !== 'manual' && scm_type.value !== 'insights'",
ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)',
subForm: 'sourceSubForm',
},
credential: {
label: i18n._('SCM Credential'),
labelBind: 'credentialLabel',
type: 'lookup',
basePath: 'credentials',
list: 'CredentialList',
@ -135,6 +136,11 @@ angular.module('ProjectFormDefinition', ['SchedulesListDefinition'])
search: {
kind: 'scm'
},
autopopulateLookup: false,
awRequiredWhen: {
reqExpression: "credRequired",
init: false
},
ngShow: "scm_type && scm_type.value !== 'manual'",
sourceModel: 'credential',
awLookupType: 'scm_credential',

View File

@ -15,7 +15,7 @@ export default
.factory('TeamForm', ['i18n', function(i18n) {
return {
addTitle: i18n._('New Team'), //Legend in add mode
addTitle: i18n._('NEW TEAM'), //Legend in add mode
editTitle: '{{ name }}', //Legend in edit mode
name: 'team',
// the top-most node of generated state tree
@ -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

@ -15,7 +15,7 @@ export default
.factory('UserForm', ['i18n', function(i18n) {
return {
addTitle: i18n._('New User'),
addTitle: i18n._('NEW USER'),
editTitle: '{{ username }}',
name: 'user',
// the top-most node of generated state tree
@ -38,6 +38,17 @@ export default
required: true,
capitalize: true
},
organization: {
label: i18n._('Organization'),
type: 'lookup',
list: 'OrganizationList',
basePath: 'organizations',
sourceModel: 'organization',
sourceField: 'name',
required: true,
excludeMode: 'edit',
ngDisabled: '!(user_obj.summary_fields.user_capabilities.edit || canAdd)'
},
email: {
label: i18n._('Email'),
type: 'email',
@ -55,17 +66,6 @@ export default
autocomplete: false,
ngDisabled: '!(user_obj.summary_fields.user_capabilities.edit || canAdd)'
},
organization: {
label: i18n._('Organization'),
type: 'lookup',
list: 'OrganizationList',
basePath: 'organizations',
sourceModel: 'organization',
sourceField: 'name',
required: true,
excludeMode: 'edit',
ngDisabled: '!(user_obj.summary_fields.user_capabilities.edit || canAdd)'
},
password: {
label: i18n._('Password'),
type: 'sensitive',

View File

@ -16,7 +16,7 @@ export default
.factory('WorkflowFormObject', ['i18n', function(i18n) {
return {
addTitle: i18n._('New Workflow Job Template'),
addTitle: i18n._('NEW WORKFLOW JOB TEMPLATE'),
editTitle: '{{ name }}',
name: 'workflow_job_template',
breadcrumbName: i18n._('WORKFLOW'),
@ -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')
}
});
@ -97,7 +100,7 @@ angular.module('inventory', [
'@': {
templateProvider: function(ScheduleList, generateList, ParentObject) {
// include name of parent resource in listTitle
ScheduleList.listTitle = `${ParentObject.name}<div class='List-titleLockup'></div>` + N_('Schedules');
ScheduleList.listTitle = `${ParentObject.name}<div class='List-titleLockup'></div>` + N_('SCHEDULES');
let html = generateList.build({
list: ScheduleList,
mode: 'edit'

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,9 +10,9 @@
* @description This form is for executing an adhoc command
*/
export default function() {
export default ['i18n', function(i18n) {
return {
addTitle: 'Execute Command',
addTitle: 'EXECUTE COMMAND',
name: 'adhoc',
well: true,
forceListeners: true,
@ -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

@ -13,7 +13,7 @@
export default ['i18n', function(i18n) {
return {
addTitle: i18n._('New Custom Inventory'),
addTitle: i18n._('NEW CUSTOM INVENTORY'),
editTitle: '{{ name }}',
name: 'inventory_script',
basePath: 'inventory_scripts',

View File

@ -9,7 +9,7 @@
export default ['i18n', function(i18n){
return {
name: 'inventory_scripts' ,
listTitle: i18n._('Inventory Scripts'),
listTitle: i18n._('INVENTORY SCRIPTS'),
iterator: 'inventory_script',
index: false,
hover: false,

View File

@ -57,7 +57,6 @@
font-size: 14px;
font-weight: bold;
margin-right: 10px;
text-transform: uppercase;
}
.JobDetail-panelHeaderText:hover{

View File

@ -9,12 +9,12 @@
</a>
<span class="HostEvent-title">{{event.host_name}}</span>
<!-- close -->
<button ui-sref="jobDetail" type="button" class="close">
<button ng-click="closeHostEvent()" type="button" class="close">
<i class="fa fa-times-circle"></i>
</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>
@ -64,7 +64,7 @@
<!-- controls -->
<div class="HostEvent-controls">
<button ui-sref="jobDetail" class="btn btn-sm btn-default HostEvent-close">Close</button>
<button ng-click="closeHostEvent()" class="btn btn-sm btn-default HostEvent-close">Close</button>
</div>
</div>
</div>

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

@ -27,7 +27,8 @@
var container = document.getElementById(el);
var editor = CodeMirror.fromTextArea(container, { // jshint ignore:line
lineNumbers: true,
mode: mode
mode: mode,
readOnly: true
});
editor.setSize("100%", 200);
editor.getDoc().setValue(data);
@ -44,6 +45,12 @@
return $scope.hostResults.indexOf(result[0]);
};
$scope.closeHostEvent = function() {
// Unbind the listener so it doesn't fire when we close the modal via navigation
$('#HostEvent').off('hidden.bs.modal');
$state.go('jobDetail');
};
var init = function(){
hostEvent.event_name = hostEvent.event;
$scope.event = _.cloneDeep(hostEvent);
@ -97,6 +104,10 @@
}
}
$('#HostEvent').modal('show');
$('#HostEvent').on('hidden.bs.modal', function () {
$scope.closeHostEvent();
});
};
init();
}];

View File

@ -161,6 +161,24 @@
flex-direction: column;
}
.JobResults-panelRightTitle{
flex-wrap: wrap;
}
.JobResults-panelRightTitleText{
word-wrap: break-word;
word-break: break-all;
max-width: 100%;
}
.JobResults-badgeAndActionRow{
display:flex;
flex: 1 0 auto;
justify-content: flex-end;
flex-wrap: wrap;
max-width: 100%;
}
.StandardOut-panelHeader {
flex: initial;
}
@ -195,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

@ -39,11 +39,7 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy
// used for tag search
$scope.list = {
basePath: jobData.related.job_events,
defaultSearchParams: function(term){
return {
or__stdout__icontains: term,
};
},
name: 'job_events'
};
// used for tag search
@ -455,13 +451,6 @@ function(jobData, jobDataOptions, jobLabels, jobFinished, count, $scope, ParseTy
var getSkeleton = function(url) {
jobResultsService.getEvents(url)
.then(events => {
// old job check: if the job is complete, there is result stdout, and
// there are no job events, it's an old job
if ($scope.jobFinished) {
$scope.showLegacyJobErrorMessage = $scope.job.result_stdout.length &&
!events.results.length;
}
events.results.forEach(event => {
if (event.start_line === 0 && event.end_line === 0) {
$scope.isOld++;

View File

@ -431,8 +431,8 @@
<div class="Panel JobResults-panelRight">
<!-- RIGHT PANE HEADER -->
<div class="StandardOut-panelHeader">
<div class="StandardOut-panelHeaderText">
<div class="StandardOut-panelHeader JobResults-panelRightTitle">
<div class="StandardOut-panelHeaderText JobResults-panelRightTitleText">
<i class="JobResults-statusResultIcon
fa icon-job-{{ job_status }}"
ng-show="stdoutFullScreen"
@ -443,76 +443,77 @@
</i>
{{ job.name }}
</div>
<div class="JobResults-badgeAndActionRow">
<!-- HEADER COUNTS -->
<div class="JobResults-badgeRow">
<!-- PLAYS COUNT -->
<div class="JobResults-badgeTitle">
Plays
</div>
<span class="badge List-titleBadge">
{{ playCount || 0}}
</span>
<!-- HEADER COUNTS -->
<div class="JobResults-badgeRow">
<!-- PLAYS COUNT -->
<div class="JobResults-badgeTitle">
Plays
<!-- TASKS COUNT -->
<div class="JobResults-badgeTitle">
Tasks
</div>
<span class="badge List-titleBadge">
{{ taskCount || 0}}
</span>
<!-- HOSTS COUNT -->
<div class="JobResults-badgeTitle">
Hosts
</div>
<span class="badge List-titleBadge"
ng-if="jobFinished">
{{ hostCount || 0}}
</span>
<span class="badge List-titleBadge"
aw-tool-tip="The host count will update when the job is complete."
data-placement="top"
ng-if="!jobFinished">
<i class="fa fa-ellipsis-h"></i>
</span>
<!-- ELAPSED TIME -->
<div class="JobResults-badgeTitle">
Elapsed
</div>
<span class="badge List-titleBadge">
{{ job.elapsed * 1000 | duration: "hh:mm:ss" }}
</span>
</div>
<span class="badge List-titleBadge">
{{ playCount || 0}}
</span>
<!-- TASKS COUNT -->
<div class="JobResults-badgeTitle">
Tasks
</div>
<span class="badge List-titleBadge">
{{ taskCount || 0}}
</span>
<!-- HEADER ACTIONS -->
<div class="StandardOut-panelHeaderActions">
<!-- HOSTS COUNT -->
<div class="JobResults-badgeTitle">
Hosts
</div>
<span class="badge List-titleBadge"
ng-if="jobFinished">
{{ hostCount || 0}}
</span>
<span class="badge List-titleBadge"
aw-tool-tip="The host count will update when the job is complete."
data-placement="top"
ng-if="!jobFinished">
<i class="fa fa-ellipsis-h"></i>
</span>
<!-- ELAPSED TIME -->
<div class="JobResults-badgeTitle">
Elapsed
</div>
<span class="badge List-titleBadge">
{{ job.elapsed * 1000 | duration: "hh:mm:ss" }}
</span>
</div>
<!-- HEADER ACTIONS -->
<div class="StandardOut-panelHeaderActions">
<!-- FULL-SCREEN TOGGLE ACTION -->
<button class="StandardOut-actionButton"
aw-tool-tip="{{ toggleStdoutFullscreenTooltip }}"
data-tip-watch="toggleStdoutFullscreenTooltip"
data-placement="top"
ng-class="{'StandardOut-actionButton--active': stdoutFullScreen}"
ng-click="toggleStdoutFullscreen()">
<i class="fa fa-arrows-alt"></i>
</button>
<!-- DOWNLOAD ACTION -->
<a ng-show="job.status === 'failed' ||
job.status === 'successful' ||
job.status === 'canceled'"
href="/api/v1/jobs/{{ job.id }}/stdout?format=txt_download">
<!-- FULL-SCREEN TOGGLE ACTION -->
<button class="StandardOut-actionButton"
aw-tool-tip="{{ standardOutTooltip }}"
data-tip-watch="standardOutTooltip"
data-placement="top">
<i class="fa fa-download"></i>
aw-tool-tip="{{ toggleStdoutFullscreenTooltip }}"
data-tip-watch="toggleStdoutFullscreenTooltip"
data-placement="top"
ng-class="{'StandardOut-actionButton--active': stdoutFullScreen}"
ng-click="toggleStdoutFullscreen()">
<i class="fa fa-arrows-alt"></i>
</button>
</a>
<!-- DOWNLOAD ACTION -->
<a ng-show="job.status === 'failed' ||
job.status === 'successful' ||
job.status === 'canceled'"
href="/api/v1/jobs/{{ job.id }}/stdout?format=txt_download">
<button class="StandardOut-actionButton"
aw-tool-tip="{{ standardOutTooltip }}"
data-tip-watch="standardOutTooltip"
data-placement="top">
<i class="fa fa-download"></i>
</button>
</a>
</div>
</div>
</div>
<host-status-bar></host-status-bar>

View File

@ -23,19 +23,20 @@
.JobSubmission-header {
display: flex;
flex: 0 0 auto;
align-items: center;
}
.JobSubmission-title {
align-items: center;
flex: 1 0 auto;
display: flex;
word-wrap: break-word;
word-break: break-all;
max-width: 98%;
}
.JobSubmission-titleText {
color: @list-title-txt;
font-size: 14px;
font-weight: bold;
margin-right: 10px;
text-transform: uppercase;
}
.JobSubmission-titleLockup {
margin-left: 4px;
@ -174,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

@ -13,7 +13,7 @@ export default
name: 'jobs',
basePath: 'unified_jobs',
iterator: 'job',
editTitle: i18n._('All Jobs'),
editTitle: i18n._('ALL JOBS'),
index: false,
hover: true,
well: false,

View File

@ -14,7 +14,7 @@ export default
name: 'cloudcredentials',
iterator: 'cloudcredential',
selectTitle: 'Add Cloud Credentials',
editTitle: 'Cloud Credentials',
editTitle: 'CLOUD CREDENTIALS',
selectInstructions: '<p>Select existing credentials by clicking each credential or checking the related checkbox. When finished, click the blue ' +
'<em>Select</em> button, located bottom right.</p> <p>Create a brand new credential by clicking the <i class=\"fa fa-plus"></i> button.</p>',
index: false,

View File

@ -15,7 +15,7 @@ export default
name: 'completed_jobs',
basePath: 'api/v1/job_templates/{{$stateParams.job_template_id}}/jobs/?or__status=successful&or__status=failed&or__status=error&or__status=canceled',
iterator: 'completed_job',
editTitle: i18n._('Completed Jobs'),
editTitle: i18n._('COMPLETED JOBS'),
index: false,
hover: true,
well: false,

View File

@ -15,8 +15,8 @@ export default
name: 'credentials',
iterator: 'credential',
selectTitle: i18n._('Add Credentials'),
editTitle: i18n._('Credentials'),
listTitle: i18n._('Credentials'),
editTitle: i18n._('CREDENTIALS'),
listTitle: i18n._('CREDENTIALS'),
selectInstructions: "<p>Select existing credentials by clicking each credential or checking the related checkbox. When " +
"finished, click the blue <em>Select</em> button, located bottom right.</p> <p>Create a brand new credential by clicking ",
index: false,

View File

@ -13,8 +13,8 @@ export default
name: 'inventories',
iterator: 'inventory',
selectTitle: i18n._('Add Inventories'),
editTitle: i18n._('Inventories'),
listTitle: i18n._('Inventories'),
editTitle: i18n._('INVENTORIES'),
listTitle: i18n._('INVENTORIES'),
selectInstructions: i18n.sprintf(i18n._("Click on a row to select it, and click Finished when done. Click the %s button to create a new inventory."), "<i class=\"icon-plus\"></i> "),
index: false,
hover: true,

View File

@ -11,7 +11,7 @@ export default
name: 'groups',
iterator: 'group',
editTitle: '{{ inventory.name }}',
listTitle: 'Groups',
listTitle: 'GROUPS',
searchSize: 'col-lg-12 col-md-12 col-sm-12 col-xs-12',
showTitle: false,
well: true,

View File

@ -11,7 +11,7 @@ export default
name: 'hosts',
iterator: 'host',
editTitle: '{{ selected_group }}',
listTitle: 'Hosts',
listTitle: 'HOSTS',
searchSize: 'col-lg-12 col-md-12 col-sm-12 col-xs-12',
showTitle: false,
well: true,

View File

@ -12,7 +12,7 @@ export default
name: 'workflow_inventory_sources',
iterator: 'inventory_source',
basePath: 'inventory_sources',
listTitle: 'Inventory Sources',
listTitle: 'INVENTORY SOURCES',
index: false,
hover: true,

View File

@ -12,7 +12,7 @@ export default
name: 'jobevents',
iterator: 'jobevent',
editTitle: i18n._('Job Events'),
editTitle: i18n._('JOB EVENTS'),
index: false,
hover: true,
"class": "condensed",

View File

@ -11,7 +11,7 @@ export default
name: 'jobhosts',
iterator: 'jobhost',
editTitle: 'All summaries',
editTitle: 'ALL SUMMARIES',
"class": "table-condensed",
index: false,
hover: true,

View File

@ -11,7 +11,7 @@ export default
name: 'jobs',
iterator: 'job',
editTitle: 'Jobs',
editTitle: 'JOBS',
'class': 'table-condensed',
index: false,
hover: true,

View File

@ -15,7 +15,7 @@ export default
selectInstructions: '<p>Select existing organizations by clicking each organization or checking the related checkbox. When finished, ' +
'click the blue <em>Select</em> button, located bottom right.</p><p>Create a new organization by clicking the ' +
'<i class=\"fa fa-plus\"></i> button.</p>',
editTitle: 'Organizations',
editTitle: 'ORGANIZATIONS',
hover: true,
index: false,

Some files were not shown because too many files have changed in this diff Show More