Merge pull request #1592 from ryanpetrello/release_3.3.0

3.2.4 -> 3.3.0
This commit is contained in:
Ryan Petrello 2018-04-26 11:34:16 -04:00 committed by GitHub
commit a009d21edc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 481 additions and 25 deletions

View File

@ -326,3 +326,28 @@ def test_callback_plugin_records_notify_events(executor, cache, playbook):
assert notify_events[0]['event_data']['handler'] == 'my_handler'
assert notify_events[0]['event_data']['host'] == 'localhost'
assert notify_events[0]['event_data']['task'] == 'debug'
@pytest.mark.parametrize('playbook', [
{'no_log_module_with_var.yml': '''
- name: ensure that module-level secrets are redacted
connection: local
hosts: all
vars:
- pw: SENSITIVE
tasks:
- uri:
url: https://example.org
user: john-jacob-jingleheimer-schmidt
password: "{{ pw }}"
'''}, # noqa
])
def test_module_level_no_log(executor, cache, playbook):
# https://github.com/ansible/tower/issues/1101
# It's possible for `no_log=True` to be defined at the _module_ level,
# e.g., for the URI module password parameter
# This test ensures that we properly redact those
executor.run()
assert len(cache)
assert 'john-jacob-jingleheimer-schmidt' in json.dumps(cache.items())
assert 'SENSITIVE' not in json.dumps(cache.items())

View File

@ -135,6 +135,27 @@ register(
required=False,
)
register(
'ALLOW_JINJA_IN_EXTRA_VARS',
field_class=fields.ChoiceField,
choices=[
('always', _('Always')),
('never', _('Never')),
('template', _('Only On Job Template Definitions')),
],
required=True,
label=_('When can extra variables contain Jinja templates?'),
help_text=_(
'Ansible allows variable substitution via the Jinja2 templating '
'language for --extra-vars. This poses a potential security '
'risk where Tower users with the ability to specify extra vars at job '
'launch time can use Jinja2 templates to run arbitrary Python. It is '
'recommended that this value be set to "template" or "never".'
),
category=_('Jobs'),
category_slug='jobs',
)
register(
'AWX_PROOT_ENABLED',
field_class=fields.BooleanField,

View File

@ -2,7 +2,6 @@
# All Rights Reserved.
from collections import OrderedDict
import functools
import json
import logging
import os
import re
@ -25,6 +24,7 @@ from awx.main.fields import (ImplicitRoleField, CredentialInputField,
CredentialTypeInputField,
CredentialTypeInjectorField)
from awx.main.utils import decrypt_field
from awx.main.utils.safe_yaml import safe_dump
from awx.main.validators import validate_ssh_private_key
from awx.main.models.base import * # noqa
from awx.main.models.mixins import ResourceMixin
@ -652,25 +652,20 @@ class CredentialType(CommonModelNameNotUnique):
if 'INVENTORY_UPDATE_ID' not in env:
# awx-manage inventory_update does not support extra_vars via -e
extra_vars = {}
safe_extra_vars = {}
for var_name, tmpl in self.injectors.get('extra_vars', {}).items():
extra_vars[var_name] = Template(tmpl).render(**namespace)
safe_extra_vars[var_name] = Template(tmpl).render(**safe_namespace)
def build_extra_vars_file(vars, private_dir):
handle, path = tempfile.mkstemp(dir = private_dir)
f = os.fdopen(handle, 'w')
f.write(json.dumps(vars))
f.write(safe_dump(vars))
f.close()
os.chmod(path, stat.S_IRUSR)
return path
path = build_extra_vars_file(extra_vars, private_data_dir)
if extra_vars:
path = build_extra_vars_file(extra_vars, private_data_dir)
args.extend(['-e', '@%s' % path])
if safe_extra_vars:
path = build_extra_vars_file(safe_extra_vars, private_data_dir)
safe_args.extend(['-e', '@%s' % path])

View File

@ -58,6 +58,7 @@ from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field,
check_proot_installed, build_proot_temp_dir, get_licenser,
wrap_args_with_proot, OutputEventFilter, OutputVerboseFilter, ignore_inventory_computed_fields,
ignore_inventory_group_removal, get_type_for_model, extract_ansible_vars)
from awx.main.utils.safe_yaml import safe_dump, sanitize_jinja
from awx.main.utils.reload import restart_local_services, stop_local_services
from awx.main.utils.pglock import advisory_lock
from awx.main.utils.ha import update_celery_worker_routes, register_celery_worker_queues
@ -727,7 +728,10 @@ class BaseTask(Task):
def build_extra_vars_file(self, vars, **kwargs):
handle, path = tempfile.mkstemp(dir=kwargs.get('private_data_dir', None))
f = os.fdopen(handle, 'w')
f.write(json.dumps(vars))
if settings.ALLOW_JINJA_IN_EXTRA_VARS == 'always':
f.write(yaml.safe_dump(vars))
else:
f.write(safe_dump(vars, kwargs.get('safe_dict', {}) or None))
f.close()
os.chmod(path, stat.S_IRUSR)
return path
@ -741,7 +745,6 @@ class BaseTask(Task):
raise RuntimeError(
'a valid Python virtualenv does not exist at {}'.format(venv_path)
)
env.pop('PYTHONPATH', None) # default to none if no python_ver matches
if os.path.isdir(os.path.join(venv_libdir, "python2.7")):
env['PYTHONPATH'] = os.path.join(venv_libdir, "python2.7", "site-packages") + ":"
@ -1220,7 +1223,7 @@ class RunJob(BaseTask):
args = ['ansible-playbook', '-i', self.build_inventory(job, **kwargs)]
if job.job_type == 'check':
args.append('--check')
args.extend(['-u', ssh_username])
args.extend(['-u', sanitize_jinja(ssh_username)])
if 'ssh_password' in kwargs.get('passwords', {}):
args.append('--ask-pass')
if job.become_enabled:
@ -1228,9 +1231,9 @@ class RunJob(BaseTask):
if job.diff_mode:
args.append('--diff')
if become_method:
args.extend(['--become-method', become_method])
args.extend(['--become-method', sanitize_jinja(become_method)])
if become_username:
args.extend(['--become-user', become_username])
args.extend(['--become-user', sanitize_jinja(become_username)])
if 'become_password' in kwargs.get('passwords', {}):
args.append('--ask-become-pass')
@ -1267,7 +1270,20 @@ class RunJob(BaseTask):
extra_vars.update(json.loads(job.display_extra_vars()))
else:
extra_vars.update(json.loads(job.decrypted_extra_vars()))
extra_vars_path = self.build_extra_vars_file(vars=extra_vars, **kwargs)
# By default, all extra vars disallow Jinja2 template usage for
# security reasons; top level key-values defined in JT.extra_vars, however,
# are whitelisted as "safe" (because they can only be set by users with
# higher levels of privilege - those that have the ability create and
# edit Job Templates)
safe_dict = {}
if job.job_template and settings.ALLOW_JINJA_IN_EXTRA_VARS == 'template':
safe_dict = job.job_template.extra_vars_dict
extra_vars_path = self.build_extra_vars_file(
vars=extra_vars,
safe_dict=safe_dict,
**kwargs
)
args.extend(['-e', '@%s' % (extra_vars_path)])
# Add path to playbook (relative to project.local_path).
@ -2197,7 +2213,7 @@ class RunAdHocCommand(BaseTask):
args = ['ansible', '-i', self.build_inventory(ad_hoc_command, **kwargs)]
if ad_hoc_command.job_type == 'check':
args.append('--check')
args.extend(['-u', ssh_username])
args.extend(['-u', sanitize_jinja(ssh_username)])
if 'ssh_password' in kwargs.get('passwords', {}):
args.append('--ask-pass')
# We only specify sudo/su user and password if explicitly given by the
@ -2205,9 +2221,9 @@ class RunAdHocCommand(BaseTask):
if ad_hoc_command.become_enabled:
args.append('--become')
if become_method:
args.extend(['--become-method', become_method])
args.extend(['--become-method', sanitize_jinja(become_method)])
if become_username:
args.extend(['--become-user', become_username])
args.extend(['--become-user', sanitize_jinja(become_username)])
if 'become_password' in kwargs.get('passwords', {}):
args.append('--ask-become-pass')
@ -2231,7 +2247,7 @@ class RunAdHocCommand(BaseTask):
args.extend(['-e', '@%s' % (extra_vars_path)])
args.extend(['-m', ad_hoc_command.module_name])
args.extend(['-a', ad_hoc_command.module_args])
args.extend(['-a', sanitize_jinja(ad_hoc_command.module_args)])
if ad_hoc_command.limit:
args.append(ad_hoc_command.limit)

View File

@ -1,5 +1,6 @@
import tempfile
import json
import yaml
import pytest
from awx.main.utils.encryption import encrypt_value
@ -10,6 +11,7 @@ from awx.main.models import (
JobLaunchConfig,
WorkflowJobTemplate
)
from awx.main.utils.safe_yaml import SafeLoader
ENCRYPTED_SECRET = encrypt_value('secret')
@ -122,7 +124,7 @@ def test_job_safe_args_redacted_passwords(job):
safe_args = run_job.build_safe_args(job, **kwargs)
ev_index = safe_args.index('-e') + 1
extra_var_file = open(safe_args[ev_index][1:], 'r')
extra_vars = json.load(extra_var_file)
extra_vars = yaml.load(extra_var_file, SafeLoader)
extra_var_file.close()
assert extra_vars['secret_key'] == '$encrypted$'
@ -133,7 +135,7 @@ def test_job_args_unredacted_passwords(job, tmpdir_factory):
args = run_job.build_args(job, **kwargs)
ev_index = args.index('-e') + 1
extra_var_file = open(args[ev_index][1:], 'r')
extra_vars = json.load(extra_var_file)
extra_vars = yaml.load(extra_var_file, SafeLoader)
extra_var_file.close()
assert extra_vars['secret_key'] == 'my_password'

View File

@ -28,6 +28,7 @@ from awx.main.models import (
InventorySource,
InventoryUpdate,
Job,
JobTemplate,
Notification,
Project,
ProjectUpdate,
@ -40,7 +41,7 @@ from awx.main.models import (
from awx.main import tasks
from awx.main.queue import CallbackQueueDispatcher
from awx.main.utils import encrypt_field, encrypt_value, OutputEventFilter
from awx.main.utils.safe_yaml import SafeLoader
@contextmanager
@ -191,7 +192,7 @@ def parse_extra_vars(args):
for chunk in args:
if chunk.startswith('@/tmp/'):
with open(chunk.strip('@'), 'r') as f:
extra_vars.update(json.load(f))
extra_vars.update(yaml.load(f, SafeLoader))
return extra_vars
@ -271,7 +272,8 @@ class TestJobExecution:
cancel_flag=False,
project=Project(),
playbook='helloworld.yml',
verbosity=3
verbosity=3,
job_template=JobTemplate(extra_vars='')
)
# mock the job.credentials M2M relation so we can avoid DB access
@ -297,6 +299,131 @@ class TestJobExecution:
return self.instance.pk
class TestExtraVarSanitation(TestJobExecution):
# By default, extra vars are marked as `!unsafe` in the generated yaml
# _unless_ they've been specified on the JobTemplate's extra_vars (which
# are deemed trustable, because they can only be added by users w/ enough
# privilege to add/modify a Job Template)
UNSAFE = '{{ lookup(''pipe'',''ls -la'') }}'
def test_vars_unsafe_by_default(self):
self.instance.created_by = User(pk=123, username='angry-spud')
def run_pexpect_side_effect(*args, **kwargs):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
# ensure that strings are marked as unsafe
for unsafe in ['awx_job_template_name', 'tower_job_template_name',
'awx_user_name', 'tower_job_launch_type',
'awx_project_revision',
'tower_project_revision', 'tower_user_name',
'awx_job_launch_type']:
assert hasattr(extra_vars[unsafe], '__UNSAFE__')
# ensure that non-strings are marked as safe
for safe in ['awx_job_template_id', 'awx_job_id', 'awx_user_id',
'tower_user_id', 'tower_job_template_id',
'tower_job_id']:
assert not hasattr(extra_vars[safe], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk)
def test_launchtime_vars_unsafe(self):
self.instance.extra_vars = json.dumps({'msg': self.UNSAFE})
def run_pexpect_side_effect(*args, **kwargs):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
assert extra_vars['msg'] == self.UNSAFE
assert hasattr(extra_vars['msg'], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk)
def test_nested_launchtime_vars_unsafe(self):
self.instance.extra_vars = json.dumps({'msg': {'a': [self.UNSAFE]}})
def run_pexpect_side_effect(*args, **kwargs):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
assert extra_vars['msg'] == {'a': [self.UNSAFE]}
assert hasattr(extra_vars['msg']['a'][0], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk)
def test_whitelisted_jt_extra_vars(self):
self.instance.job_template.extra_vars = self.instance.extra_vars = json.dumps({'msg': self.UNSAFE})
def run_pexpect_side_effect(*args, **kwargs):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
assert extra_vars['msg'] == self.UNSAFE
assert not hasattr(extra_vars['msg'], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk)
def test_nested_whitelisted_vars(self):
self.instance.extra_vars = json.dumps({'msg': {'a': {'b': [self.UNSAFE]}}})
self.instance.job_template.extra_vars = self.instance.extra_vars
def run_pexpect_side_effect(*args, **kwargs):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
assert extra_vars['msg'] == {'a': {'b': [self.UNSAFE]}}
assert not hasattr(extra_vars['msg']['a']['b'][0], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk)
def test_sensitive_values_dont_leak(self):
# JT defines `msg=SENSITIVE`, the job *should not* be able to do
# `other_var=SENSITIVE`
self.instance.job_template.extra_vars = json.dumps({'msg': self.UNSAFE})
self.instance.extra_vars = json.dumps({
'msg': 'other-value',
'other_var': self.UNSAFE
})
def run_pexpect_side_effect(*args, **kwargs):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
assert extra_vars['msg'] == 'other-value'
assert hasattr(extra_vars['msg'], '__UNSAFE__')
assert extra_vars['other_var'] == self.UNSAFE
assert hasattr(extra_vars['other_var'], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk)
def test_overwritten_jt_extra_vars(self):
self.instance.job_template.extra_vars = json.dumps({'msg': 'SAFE'})
self.instance.extra_vars = json.dumps({'msg': self.UNSAFE})
def run_pexpect_side_effect(*args, **kwargs):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
assert extra_vars['msg'] == self.UNSAFE
assert hasattr(extra_vars['msg'], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect
self.task.run(self.pk)
class TestGenericRun(TestJobExecution):
def test_generic_failure(self):
@ -473,6 +600,13 @@ class TestAdhocRun(TestJobExecution):
extra_vars={'awx_foo': 'awx-bar'}
)
def test_options_jinja_usage(self):
self.instance.module_args = '{{ ansible_ssh_pass }}'
with pytest.raises(Exception):
self.task.run(self.pk)
update_model_call = self.task.update_model.call_args[1]
assert 'Jinja variables are not allowed' in update_model_call['result_traceback']
def test_created_by_extra_vars(self):
self.instance.created_by = User(pk=123, username='angry-spud')
@ -584,6 +718,33 @@ class TestJobCredentials(TestJobExecution):
]
}
def test_username_jinja_usage(self):
ssh = CredentialType.defaults['ssh']()
credential = Credential(
pk=1,
credential_type=ssh,
inputs = {'username': '{{ ansible_ssh_pass }}'}
)
self.instance.credentials.add(credential)
with pytest.raises(Exception):
self.task.run(self.pk)
update_model_call = self.task.update_model.call_args[1]
assert 'Jinja variables are not allowed' in update_model_call['result_traceback']
@pytest.mark.parametrize("flag", ['become_username', 'become_method'])
def test_become_jinja_usage(self, flag):
ssh = CredentialType.defaults['ssh']()
credential = Credential(
pk=1,
credential_type=ssh,
inputs = {'username': 'joe', flag: '{{ ansible_ssh_pass }}'}
)
self.instance.credentials.add(credential)
with pytest.raises(Exception):
self.task.run(self.pk)
update_model_call = self.task.update_model.call_args[1]
assert 'Jinja variables are not allowed' in update_model_call['result_traceback']
def test_ssh_passwords(self, field, password_name, expected_flag):
ssh = CredentialType.defaults['ssh']()
credential = Credential(
@ -1171,6 +1332,7 @@ class TestJobCredentials(TestJobExecution):
args, cwd, env, stdout = args
extra_vars = parse_extra_vars(args)
assert extra_vars["api_token"] == "ABC123"
assert hasattr(extra_vars["api_token"], '__UNSAFE__')
return ['successful', 0]
self.run_pexpect.side_effect = run_pexpect_side_effect

View File

@ -0,0 +1,97 @@
# -*- coding: utf-8 -*-
from copy import deepcopy
import pytest
import yaml
from awx.main.utils.safe_yaml import safe_dump
@pytest.mark.parametrize('value', [None, 1, 1.5, []])
def test_native_types(value):
# Native non-string types should dump the same way that `yaml.safe_dump` does
assert safe_dump(value) == yaml.safe_dump(value)
def test_empty():
assert safe_dump({}) == ''
def test_raw_string():
assert safe_dump('foo') == "!unsafe 'foo'\n"
def test_kv_null():
assert safe_dump({'a': None}) == "!unsafe 'a': null\n"
def test_kv_null_safe():
assert safe_dump({'a': None}, {'a': None}) == "a: null\n"
def test_kv_null_unsafe():
assert safe_dump({'a': ''}, {'a': None}) == "!unsafe 'a': !unsafe ''\n"
def test_kv_int():
assert safe_dump({'a': 1}) == "!unsafe 'a': 1\n"
def test_kv_float():
assert safe_dump({'a': 1.5}) == "!unsafe 'a': 1.5\n"
def test_kv_unsafe():
assert safe_dump({'a': 'b'}) == "!unsafe 'a': !unsafe 'b'\n"
def test_kv_unsafe_unicode():
assert safe_dump({'a': u'🐉'}) == '!unsafe \'a\': !unsafe "\\U0001F409"\n'
def test_kv_unsafe_in_list():
assert safe_dump({'a': ['b']}) == "!unsafe 'a':\n- !unsafe 'b'\n"
def test_kv_unsafe_in_mixed_list():
assert safe_dump({'a': [1, 'b']}) == "!unsafe 'a':\n- 1\n- !unsafe 'b'\n"
def test_kv_unsafe_deep_nesting():
yaml = safe_dump({'a': [1, [{'b': {'c': [{'d': 'e'}]}}]]})
for x in ('a', 'b', 'c', 'd', 'e'):
assert "!unsafe '{}'".format(x) in yaml
def test_kv_unsafe_multiple():
assert safe_dump({'a': 'b', 'c': 'd'}) == '\n'.join([
"!unsafe 'a': !unsafe 'b'",
"!unsafe 'c': !unsafe 'd'",
""
])
def test_safe_marking():
assert safe_dump({'a': 'b'}, safe_dict={'a': 'b'}) == "a: b\n"
def test_safe_marking_mixed():
assert safe_dump({'a': 'b', 'c': 'd'}, safe_dict={'a': 'b'}) == '\n'.join([
"a: b",
"!unsafe 'c': !unsafe 'd'",
""
])
def test_safe_marking_deep_nesting():
deep = {'a': [1, [{'b': {'c': [{'d': 'e'}]}}]]}
yaml = safe_dump(deep, deepcopy(deep))
for x in ('a', 'b', 'c', 'd', 'e'):
assert "!unsafe '{}'".format(x) not in yaml
def test_deep_diff_unsafe_marking():
deep = {'a': [1, [{'b': {'c': [{'d': 'e'}]}}]]}
jt_vars = deepcopy(deep)
deep['a'][1][0]['b']['z'] = 'not safe'
yaml = safe_dump(deep, jt_vars)
assert "!unsafe 'z'" in yaml

View File

@ -0,0 +1,87 @@
import re
import six
import yaml
__all__ = ['safe_dump', 'SafeLoader']
class SafeStringDumper(yaml.SafeDumper):
def represent_data(self, value):
if isinstance(value, six.string_types):
return self.represent_scalar('!unsafe', value)
return super(SafeStringDumper, self).represent_data(value)
class SafeLoader(yaml.Loader):
def construct_yaml_unsafe(self, node):
class UnsafeText(six.text_type):
__UNSAFE__ = True
node = UnsafeText(self.construct_scalar(node))
return node
SafeLoader.add_constructor(
u'!unsafe',
SafeLoader.construct_yaml_unsafe
)
def safe_dump(x, safe_dict=None):
"""
Used to serialize an extra_vars dict to YAML
By default, extra vars are marked as `!unsafe` in the generated yaml
_unless_ they've been deemed "trusted" (meaning, they likely were set/added
by a user with a high level of privilege).
This function allows you to pass in a trusted `safe_dict` to whitelist
certain extra vars so that they are _not_ marked as `!unsafe` in the
resulting YAML. Anything _not_ in this dict will automatically be
`!unsafe`.
safe_dump({'a': 'b', 'c': 'd'}) ->
!unsafe 'a': !unsafe 'b'
!unsafe 'c': !unsafe 'd'
safe_dump({'a': 'b', 'c': 'd'}, safe_dict={'a': 'b'})
a: b
!unsafe 'c': !unsafe 'd'
"""
if isinstance(x, dict):
yamls = []
safe_dict = safe_dict or {}
# Compare the top level keys so that we can find values that have
# equality matches (and consider those branches safe)
for k, v in x.items():
dumper = yaml.SafeDumper
if k not in safe_dict or safe_dict.get(k) != v:
dumper = SafeStringDumper
yamls.append(yaml.dump_all(
[{k: v}],
None,
Dumper=dumper,
default_flow_style=False,
))
return ''.join(yamls)
else:
return yaml.dump_all([x], None, Dumper=SafeStringDumper, default_flow_style=False)
def sanitize_jinja(arg):
"""
For some string, prevent usage of Jinja-like flags
"""
if isinstance(arg, six.string_types):
# If the argument looks like it contains Jinja expressions
# {{ x }} ...
if re.search('\{\{[^}]+}}', arg) is not None:
raise ValueError('Inline Jinja variables are not allowed.')
# If the argument looks like it contains Jinja statements/control flow...
# {% if x.foo() %} ...
if re.search('\{%[^%]+%}', arg) is not None:
raise ValueError('Inline Jinja variables are not allowed.')
return arg

View File

@ -169,6 +169,10 @@ STDOUT_MAX_BYTES_DISPLAY = 1048576
# on how many events to display before truncating/hiding
MAX_UI_JOB_EVENTS = 4000
# Returned in index.html, tells the UI if it should make requests
# to update job data in response to status changes websocket events
UI_LIVE_UPDATES_ENABLED = True
# The maximum size of the ansible callback event's res data structure
# beyond this limit and the value will be removed
MAX_EVENT_RES_DATA = 700000
@ -644,6 +648,9 @@ CAPTURE_JOB_EVENT_HOSTS = False
# Rebuild Host Smart Inventory memberships.
AWX_REBUILD_SMART_MEMBERSHIP = False
# By default, allow arbitrary Jinja templating in extra_vars defined on a Job Template
ALLOW_JINJA_IN_EXTRA_VARS = 'template'
# Enable bubblewrap support for running jobs (playbook runs only).
# Note: This setting may be overridden by database settings.
AWX_PROOT_ENABLED = True

View File

@ -113,7 +113,11 @@ angular
$locationProvider.hashPrefix('');
}])
.config(['$logProvider', function($logProvider) {
$logProvider.debugEnabled($ENV['ng-debug'] || false);
window.debug = function(){
$logProvider.debugEnabled(!$logProvider.debugEnabled());
return $logProvider.debugEnabled();
};
window.debug(false);
}])
.config(['ngToastProvider', function(ngToastProvider) {
ngToastProvider.configure({

View File

@ -8,7 +8,8 @@ export default
['$rootScope', '$location', '$log','$state', '$q', 'i18n',
function ($rootScope, $location, $log, $state, $q, i18n) {
var needsResubscribing = false,
socketPromise = $q.defer();
socketPromise = $q.defer(),
needsRefreshAfterBlur;
return {
init: function() {
var self = this,
@ -24,6 +25,26 @@ export default
}
url = `${protocol}://${host}/websocket/`;
// only toggle background tabbed sockets if the
// UI_LIVE_UPDATES_ENABLED flag is true in the settings file
if(window.liveUpdates){
document.addEventListener('visibilitychange', function() {
$log.debug(document.visibilityState);
if(document.visibilityState === 'hidden'){
window.liveUpdates = false;
}
else if(document.visibilityState === 'visible'){
window.liveUpdates = true;
if(needsRefreshAfterBlur){
$state.go('.', null, {reload: true});
needsRefreshAfterBlur = false;
}
}
});
}
if (!$rootScope.sessionTimer || ($rootScope.sessionTimer && !$rootScope.sessionTimer.isExpired())) {
$log.debug('Socket connecting to: ' + url);
@ -75,6 +96,13 @@ export default
$log.debug('Received From Server: ' + e.data);
var data = JSON.parse(e.data), str = "";
if(!window.liveUpdates && data.group_name !== "control" && $state.current.name !== "jobResult"){
$log.debug('Message from server dropped: ' + e.data);
needsRefreshAfterBlur = true;
return;
}
if(data.group_name==="jobs" && !('status' in data)){
// we know that this must have been a
// summary complete message b/c status is missing.

View File

@ -63,3 +63,13 @@ register(
category=_('UI'),
category_slug='ui',
)
register(
'UI_LIVE_UPDATES_ENABLED',
field_class=fields.BooleanField,
label=_('Enable Live Updates in the UI'),
help_text=_('If disabled, the page will not refresh when events are received. '
'Reloading the page will be required to get the latest details.'),
category=_('UI'),
category_slug='ui',
)

View File

@ -2,6 +2,7 @@
# All Rights Reserved.
from django.views.generic.base import TemplateView, RedirectView
from django.conf import settings
class IndexView(TemplateView):
@ -9,6 +10,7 @@ class IndexView(TemplateView):
def get_context_data(self, **kwargs):
context = super(IndexView, self).get_context_data(**kwargs)
context['UI_LIVE_UPDATES_ENABLED'] = settings.UI_LIVE_UPDATES_ENABLED
# Add any additional context info here.
return context