diff --git a/Makefile b/Makefile index 0d6aaae3db..c3511d5dbf 100644 --- a/Makefile +++ b/Makefile @@ -830,7 +830,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 @@ -841,7 +841,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 diff --git a/awx/api/filters.py b/awx/api/filters.py index 47b54da9ab..87097dc308 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -3,6 +3,7 @@ # Python import re +import json # Django from django.core.exceptions import FieldError, ValidationError @@ -296,7 +297,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): diff --git a/awx/api/views.py b/awx/api/views.py index e69dc57fd4..4b13a8b2e9 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -3503,6 +3503,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 diff --git a/awx/lib/tower_display_callback/module.py b/awx/lib/tower_display_callback/module.py index c553b08853..c40c94ec5a 100644 --- a/awx/lib/tower_display_callback/module.py +++ b/awx/lib/tower_display_callback/module.py @@ -304,6 +304,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, diff --git a/awx/main/migrations/0036_v311_insights.py b/awx/main/migrations/0036_v311_insights.py new file mode 100644 index 0000000000..57baff9b5d --- /dev/null +++ b/awx/main/migrations/0036_v311_insights.py @@ -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'), + ), + ] diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 9897067843..bf60e5b77c 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -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) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 91ef460f16..6a448a1b7b 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -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(): @@ -1189,6 +1189,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 +1221,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 +1318,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: diff --git a/awx/main/tests/functional/test_python_requirements.py b/awx/main/tests/functional/test_python_requirements.py index cf937786b2..88d86cf3f3 100644 --- a/awx/main/tests/functional/test_python_requirements.py +++ b/awx/main/tests/functional/test_python_requirements.py @@ -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: diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index d8b6469f93..16b9bc6b14 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -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(**{ diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index 49d92b5f9c..50ccb3bf89 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -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'' diff --git a/awx/main/utils/formatters.py b/awx/main/utils/formatters.py index 68d0917985..868f1c50ee 100644 --- a/awx/main/utils/formatters.py +++ b/awx/main/utils/formatters.py @@ -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): diff --git a/awx/playbooks/project_update.yml b/awx/playbooks/project_update.yml index 0f4d354ff7..8fdd3349c3 100644 --- a/awx/playbooks/project_update.yml +++ b/awx/playbooks/project_update.yml @@ -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|quote}}" + password: "{{scm_password|quote}}" + 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|quote}}" + url_password: "{{scm_password|quote}}" + 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|quote}}" + url_password: "{{scm_password|quote}}" + 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 diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 0901961fa5..e673e347a7 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -862,6 +862,8 @@ 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 diff --git a/awx/sso/conf.py b/awx/sso/conf.py index fa4d70f3f8..3a2b2b77a8 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -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([ diff --git a/awx/sso/fields.py b/awx/sso/fields.py index 5d95296e8e..338178b288 100644 --- a/awx/sso/fields.py +++ b/awx/sso/fields.py @@ -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): diff --git a/awx/ui/client/legacy-styles/forms.less b/awx/ui/client/legacy-styles/forms.less index 5da836f921..d8f705e1ad 100644 --- a/awx/ui/client/legacy-styles/forms.less +++ b/awx/ui/client/legacy-styles/forms.less @@ -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 { diff --git a/awx/ui/client/legacy-styles/lists.less b/awx/ui/client/legacy-styles/lists.less index 8807fc5f93..4e007e5bea 100644 --- a/awx/ui/client/legacy-styles/lists.less +++ b/awx/ui/client/legacy-styles/lists.less @@ -147,7 +147,6 @@ table, tbody { font-size: 14px; font-weight: bold; margin-right: 10px; - text-transform: uppercase; } .List-actionHolder { diff --git a/awx/ui/client/src/about/about.block.less b/awx/ui/client/src/about/about.block.less index 8759cfa6dc..73cd7a9937 100644 --- a/awx/ui/client/src/about/about.block.less +++ b/awx/ui/client/src/about/about.block.less @@ -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; } diff --git a/awx/ui/client/src/about/about.controller.js b/awx/ui/client/src/about/about.controller.js index 2c821b9e09..159f61458a 100644 --- a/awx/ui/client/src/about/about.controller.js +++ b/awx/ui/client/src/about/about.controller.js @@ -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'); }); }; diff --git a/awx/ui/client/src/about/about.partial.html b/awx/ui/client/src/about/about.partial.html index 867a5f6035..5874221a54 100644 --- a/awx/ui/client/src/about/about.partial.html +++ b/awx/ui/client/src/about/about.partial.html @@ -1,19 +1,18 @@