diff --git a/Makefile b/Makefile index 8ed3aee8f3..14564df430 100644 --- a/Makefile +++ b/Makefile @@ -72,7 +72,7 @@ UI_RELEASE_FLAG_FILE = awx/ui/.release_built I18N_FLAG_FILE = .i18n_built -.PHONY: clean clean-tmp clean-venv requirements requirements_dev \ +.PHONY: awx-link clean clean-tmp clean-venv requirements requirements_dev \ develop refresh adduser migrate dbchange dbshell runserver celeryd \ receiver test test_unit test_ansible test_coverage coverage_html \ dev_build release_build release_clean sdist \ @@ -234,7 +234,7 @@ migrate: if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/awx/bin/activate; \ fi; \ - $(MANAGEMENT_COMMAND) migrate --noinput --fake-initial + $(MANAGEMENT_COMMAND) migrate --noinput # Run after making changes to the models to create a new migration. dbchange: @@ -323,7 +323,7 @@ celeryd: @if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/awx/bin/activate; \ fi; \ - celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=$(CELERY_SCHEDULE_FILE) -n celery@$(COMPOSE_HOST) --pidfile /tmp/celery_pid + celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=$(CELERY_SCHEDULE_FILE) --pidfile /tmp/celery_pid # Run to start the zeromq callback receiver receiver: @@ -367,6 +367,11 @@ swagger: reports check: flake8 pep8 # pyflakes pylint +awx-link: + cp -R /tmp/awx.egg-info /awx_devel/ || true + sed -i "s/placeholder/$(shell git describe --long | sed 's/\./\\./g')/" /awx_devel/awx.egg-info/PKG-INFO + cp /tmp/awx.egg-link /venv/awx/lib/python2.7/site-packages/awx.egg-link + TEST_DIRS ?= awx/main/tests/unit awx/main/tests/functional awx/conf/tests awx/sso/tests # Run all API unit tests. test: @@ -375,6 +380,8 @@ test: fi; \ py.test $(TEST_DIRS) +test_combined: test_ansible test + test_unit: @if [ "$(VENV_BASE)" ]; then \ . $(VENV_BASE)/awx/bin/activate; \ diff --git a/awx/api/conf.py b/awx/api/conf.py index b91cdae254..34bf305f20 100644 --- a/awx/api/conf.py +++ b/awx/api/conf.py @@ -10,6 +10,7 @@ register( 'SESSION_COOKIE_AGE', field_class=fields.IntegerField, min_value=60, + max_value=30000000000, # approx 1,000 years, higher values give OverflowError label=_('Idle Time Force Log Out'), help_text=_('Number of seconds that a user is inactive before they will need to login again.'), category=_('Authentication'), diff --git a/awx/api/fields.py b/awx/api/fields.py index 6a1ddb6018..5276ef4dec 100644 --- a/awx/api/fields.py +++ b/awx/api/fields.py @@ -3,12 +3,14 @@ # Django from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ObjectDoesNotExist # Django REST Framework from rest_framework import serializers # AWX from awx.conf import fields +from awx.main.models import Credential __all__ = ['BooleanNullField', 'CharNullField', 'ChoiceNullField', 'VerbatimField'] @@ -87,3 +89,20 @@ class OAuth2ProviderField(fields.DictField): if invalid_flags: self.fail('invalid_key_names', invalid_key_names=', '.join(list(invalid_flags))) return data + + +class DeprecatedCredentialField(serializers.IntegerField): + + def __init__(self, **kwargs): + kwargs['allow_null'] = True + kwargs['default'] = None + kwargs['min_value'] = 1 + kwargs['help_text'] = 'This resource has been deprecated and will be removed in a future release' + super(DeprecatedCredentialField, self).__init__(**kwargs) + + def to_internal_value(self, pk): + try: + Credential.objects.get(pk=pk) + except ObjectDoesNotExist: + raise serializers.ValidationError(_('Credential {} does not exist').format(pk)) + return pk diff --git a/awx/api/generics.py b/awx/api/generics.py index cf5b2002ee..e12949515b 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -23,7 +23,7 @@ from django.contrib.auth import views as auth_views # Django REST Framework from rest_framework.authentication import get_authorization_header -from rest_framework.exceptions import PermissionDenied, AuthenticationFailed +from rest_framework.exceptions import PermissionDenied, AuthenticationFailed, ParseError from rest_framework import generics from rest_framework.response import Response from rest_framework import status @@ -165,6 +165,9 @@ class APIView(views.APIView): request.drf_request_user = getattr(drf_request, 'user', False) except AuthenticationFailed: request.drf_request_user = None + except ParseError as exc: + request.drf_request_user = None + self.__init_request_error__ = exc return drf_request def finalize_response(self, request, response, *args, **kwargs): @@ -174,6 +177,8 @@ class APIView(views.APIView): if response.status_code >= 400: status_msg = "status %s received by user %s attempting to access %s from %s" % \ (response.status_code, request.user, request.path, request.META.get('REMOTE_ADDR', None)) + if hasattr(self, '__init_request_error__'): + response = self.handle_exception(self.__init_request_error__) if response.status_code == 401: logger.info(status_msg) else: diff --git a/awx/api/metadata.py b/awx/api/metadata.py index bc44deb6f0..ebeb5f3286 100644 --- a/awx/api/metadata.py +++ b/awx/api/metadata.py @@ -44,9 +44,9 @@ class Metadata(metadata.SimpleMetadata): if placeholder is not serializers.empty: field_info['placeholder'] = placeholder - # Update help text for common fields. serializer = getattr(field, 'parent', None) - if serializer: + if serializer and hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'model'): + # Update help text for common fields. field_help_text = { 'id': _('Database ID for this {}.'), 'name': _('Name of this {}.'), @@ -59,10 +59,18 @@ class Metadata(metadata.SimpleMetadata): 'modified': _('Timestamp when this {} was last modified.'), } if field.field_name in field_help_text: - if hasattr(serializer, 'Meta') and hasattr(serializer.Meta, 'model'): - opts = serializer.Meta.model._meta.concrete_model._meta - verbose_name = smart_text(opts.verbose_name) - field_info['help_text'] = field_help_text[field.field_name].format(verbose_name) + opts = serializer.Meta.model._meta.concrete_model._meta + verbose_name = smart_text(opts.verbose_name) + field_info['help_text'] = field_help_text[field.field_name].format(verbose_name) + # If field is not part of the model, then show it as non-filterable + else: + is_model_field = False + for model_field in serializer.Meta.model._meta.fields: + if field.field_name == model_field.name: + is_model_field = True + break + if not is_model_field: + field_info['filterable'] = False # Indicate if a field has a default value. # FIXME: Still isn't showing all default values? diff --git a/awx/api/parsers.py b/awx/api/parsers.py index 1eb005eaeb..5f26937c45 100644 --- a/awx/api/parsers.py +++ b/awx/api/parsers.py @@ -1,7 +1,6 @@ # Python from collections import OrderedDict import json -import yaml # Django from django.conf import settings @@ -13,36 +12,6 @@ from rest_framework import parsers from rest_framework.exceptions import ParseError -class OrderedDictLoader(yaml.SafeLoader): - """ - This yaml loader is used to deal with current pyYAML (3.12) not supporting - custom object pairs hook. Remove it when new version adds that support. - """ - - def construct_mapping(self, node, deep=False): - if isinstance(node, yaml.nodes.MappingNode): - self.flatten_mapping(node) - else: - raise yaml.constructor.ConstructorError( - None, None, - "expected a mapping node, but found %s" % node.id, - node.start_mark - ) - mapping = OrderedDict() - for key_node, value_node in node.value: - key = self.construct_object(key_node, deep=deep) - try: - hash(key) - except TypeError as exc: - raise yaml.constructor.ConstructorError( - "while constructing a mapping", node.start_mark, - "found unacceptable key (%s)" % exc, key_node.start_mark - ) - value = self.construct_object(value_node, deep=deep) - mapping[key] = value - return mapping - - class JSONParser(parsers.JSONParser): """ Parses JSON-serialized data, preserving order of dictionary keys. diff --git a/awx/api/permissions.py b/awx/api/permissions.py index 3224b555e9..1567158f0e 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -233,8 +233,5 @@ class InstanceGroupTowerPermission(ModelAccessPermission): def has_object_permission(self, request, view, obj): if request.method == 'DELETE' and obj.name == "tower": return False - if request.method in ['PATCH', 'PUT'] and obj.name == 'tower' and \ - request and request.data and request.data.get('name', '') != 'tower': - return False return super(InstanceGroupTowerPermission, self).has_object_permission(request, view, obj) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index c3b6295c5d..c774898d0d 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -5,6 +5,7 @@ import copy import json import logging +import operator import re import six import urllib @@ -38,9 +39,14 @@ from rest_framework.utils.serializer_helpers import ReturnList from polymorphic.models import PolymorphicModel # AWX -from awx.main.constants import SCHEDULEABLE_PROVIDERS, ANSI_SGR_PATTERN +from awx.main.constants import ( + SCHEDULEABLE_PROVIDERS, + ANSI_SGR_PATTERN, + ACTIVE_STATES, + TOKEN_CENSOR, + CHOICES_PRIVILEGE_ESCALATION_METHODS, +) from awx.main.models import * # noqa -from awx.main.constants import ACTIVE_STATES from awx.main.models.base import NEW_JOB_TYPE_CHOICES from awx.main.access import get_user_capabilities from awx.main.fields import ImplicitRoleField @@ -56,12 +62,11 @@ from awx.main.validators import vars_validate_or_raise from awx.conf.license import feature_enabled from awx.api.versioning import reverse, get_request_version -from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, VerbatimField +from awx.api.fields import (BooleanNullField, CharNullField, ChoiceNullField, + VerbatimField, DeprecatedCredentialField) logger = logging.getLogger('awx.api.serializers') -DEPRECATED = 'This resource has been deprecated and will be removed in a future release' - # Fields that should be summarized regardless of object type. DEFAULT_SUMMARY_FIELDS = ('id', 'name', 'description')# , 'created_by', 'modified_by')#, 'type') @@ -942,7 +947,6 @@ class UserSerializer(BaseSerializer): roles = self.reverse('api:user_roles_list', kwargs={'pk': obj.pk}), activity_stream = self.reverse('api:user_activity_stream_list', kwargs={'pk': obj.pk}), access_list = self.reverse('api:user_access_list', kwargs={'pk': obj.pk}), - applications = self.reverse('api:o_auth2_application_list', kwargs={'pk': obj.pk}), tokens = self.reverse('api:o_auth2_token_list', kwargs={'pk': obj.pk}), authorized_tokens = self.reverse('api:user_authorized_token_list', kwargs={'pk': obj.pk}), personal_tokens = self.reverse('api:o_auth2_personal_token_list', kwargs={'pk': obj.pk}), @@ -991,7 +995,7 @@ class UserAuthorizedTokenSerializer(BaseSerializer): model = OAuth2AccessToken fields = ( '*', '-name', 'description', 'user', 'token', 'refresh_token', - 'expires', 'scope', 'application', + 'expires', 'scope', 'application' ) read_only_fields = ('user', 'token', 'expires') @@ -1001,7 +1005,7 @@ class UserAuthorizedTokenSerializer(BaseSerializer): if request.method == 'POST': return obj.token else: - return '*************' + return TOKEN_CENSOR except ObjectDoesNotExist: return '' @@ -1011,12 +1015,13 @@ class UserAuthorizedTokenSerializer(BaseSerializer): if request.method == 'POST': return getattr(obj.refresh_token, 'token', '') else: - return '**************' + return TOKEN_CENSOR except ObjectDoesNotExist: return '' def create(self, validated_data): - validated_data['user'] = self.context['request'].user + current_user = self.context['request'].user + validated_data['user'] = current_user validated_data['token'] = generate_token() validated_data['expires'] = now() + timedelta( seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS @@ -1025,7 +1030,7 @@ class UserAuthorizedTokenSerializer(BaseSerializer): obj.save() if obj.application is not None: RefreshToken.objects.create( - user=self.context['request'].user, + user=current_user, token=generate_token(), application=obj.application, access_token=obj @@ -1040,13 +1045,14 @@ class OAuth2ApplicationSerializer(BaseSerializer): class Meta: model = OAuth2Application fields = ( - '*', 'description', 'user', 'client_id', 'client_secret', 'client_type', - 'redirect_uris', 'authorization_grant_type', 'skip_authorization', + '*', 'description', '-user', 'client_id', 'client_secret', 'client_type', + 'redirect_uris', 'authorization_grant_type', 'skip_authorization', 'organization' ) read_only_fields = ('client_id', 'client_secret') read_only_on_update_fields = ('user', 'authorization_grant_type') extra_kwargs = { - 'user': {'allow_null': False, 'required': True}, + 'user': {'allow_null': True, 'required': False}, + 'organization': {'allow_null': False}, 'authorization_grant_type': {'allow_null': False} } @@ -1075,7 +1081,7 @@ class OAuth2ApplicationSerializer(BaseSerializer): return ret def _summary_field_tokens(self, obj): - token_list = [{'id': x.pk, 'token': '**************', 'scope': x.scope} for x in obj.oauth2accesstoken_set.all()[:10]] + token_list = [{'id': x.pk, 'token': TOKEN_CENSOR, 'scope': x.scope} for x in obj.oauth2accesstoken_set.all()[:10]] if has_model_field_prefetched(obj, 'oauth2accesstoken_set'): token_count = len(obj.oauth2accesstoken_set.all()) else: @@ -1095,6 +1101,7 @@ class OAuth2TokenSerializer(BaseSerializer): refresh_token = serializers.SerializerMethodField() token = serializers.SerializerMethodField() + ALLOWED_SCOPES = ['read', 'write'] class Meta: model = OAuth2AccessToken @@ -1103,6 +1110,10 @@ class OAuth2TokenSerializer(BaseSerializer): 'application', 'expires', 'scope', ) read_only_fields = ('user', 'token', 'expires') + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True}, + 'user': {'allow_null': False, 'required': True} + } def get_modified(self, obj): if obj is None: @@ -1128,7 +1139,7 @@ class OAuth2TokenSerializer(BaseSerializer): if request.method == 'POST': return obj.token else: - return '*************' + return TOKEN_CENSOR except ObjectDoesNotExist: return '' @@ -1138,12 +1149,31 @@ class OAuth2TokenSerializer(BaseSerializer): if request.method == 'POST': return getattr(obj.refresh_token, 'token', '') else: - return '**************' + return TOKEN_CENSOR except ObjectDoesNotExist: return '' + def _is_valid_scope(self, value): + if not value or (not isinstance(value, six.string_types)): + return False + words = value.split() + for word in words: + if words.count(word) > 1: + return False # do not allow duplicates + if word not in self.ALLOWED_SCOPES: + return False + return True + + def validate_scope(self, value): + if not self._is_valid_scope(value): + raise serializers.ValidationError(_( + 'Must be a simple space-separated string with allowed scopes {}.' + ).format(self.ALLOWED_SCOPES)) + return value + def create(self, validated_data): - validated_data['user'] = self.context['request'].user + current_user = self.context['request'].user + validated_data['user'] = current_user validated_data['token'] = generate_token() validated_data['expires'] = now() + timedelta( seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS @@ -1154,7 +1184,7 @@ class OAuth2TokenSerializer(BaseSerializer): obj.save() if obj.application is not None: RefreshToken.objects.create( - user=obj.application.user if obj.application.user else None, + user=current_user, token=generate_token(), application=obj.application, access_token=obj @@ -1176,10 +1206,13 @@ class OAuth2AuthorizedTokenSerializer(BaseSerializer): class Meta: model = OAuth2AccessToken fields = ( - '*', '-name', 'description', 'user', 'token', 'refresh_token', + '*', '-name', 'description', '-user', 'token', 'refresh_token', 'expires', 'scope', 'application', ) read_only_fields = ('user', 'token', 'expires') + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True} + } def get_token(self, obj): request = self.context.get('request', None) @@ -1187,7 +1220,7 @@ class OAuth2AuthorizedTokenSerializer(BaseSerializer): if request.method == 'POST': return obj.token else: - return '*************' + return TOKEN_CENSOR except ObjectDoesNotExist: return '' @@ -1197,12 +1230,13 @@ class OAuth2AuthorizedTokenSerializer(BaseSerializer): if request.method == 'POST': return getattr(obj.refresh_token, 'token', '') else: - return '**************' + return TOKEN_CENSOR except ObjectDoesNotExist: return '' def create(self, validated_data): - validated_data['user'] = self.context['request'].user + current_user = self.context['request'].user + validated_data['user'] = current_user validated_data['token'] = generate_token() validated_data['expires'] = now() + timedelta( seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS @@ -1213,7 +1247,7 @@ class OAuth2AuthorizedTokenSerializer(BaseSerializer): obj.save() if obj.application is not None: RefreshToken.objects.create( - user=obj.application.user if obj.application.user else None, + user=current_user, token=generate_token(), application=obj.application, access_token=obj @@ -1233,6 +1267,9 @@ class OAuth2PersonalTokenSerializer(BaseSerializer): 'application', 'expires', 'scope', ) read_only_fields = ('user', 'token', 'expires', 'application') + extra_kwargs = { + 'scope': {'allow_null': False, 'required': True} + } def get_modified(self, obj): if obj is None: @@ -1258,7 +1295,7 @@ class OAuth2PersonalTokenSerializer(BaseSerializer): if request.method == 'POST': return obj.token else: - return '*************' + return TOKEN_CENSOR except ObjectDoesNotExist: return '' @@ -1271,6 +1308,7 @@ class OAuth2PersonalTokenSerializer(BaseSerializer): validated_data['expires'] = now() + timedelta( seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS ) + validated_data['application'] = None obj = super(OAuth2PersonalTokenSerializer, self).create(validated_data) obj.save() return obj @@ -1293,6 +1331,7 @@ class OrganizationSerializer(BaseSerializer): admins = self.reverse('api:organization_admins_list', kwargs={'pk': obj.pk}), teams = self.reverse('api:organization_teams_list', kwargs={'pk': obj.pk}), credentials = self.reverse('api:organization_credential_list', kwargs={'pk': obj.pk}), + applications = self.reverse('api:organization_applications_list', kwargs={'pk': obj.pk}), activity_stream = self.reverse('api:organization_activity_stream_list', kwargs={'pk': obj.pk}), notification_templates = self.reverse('api:organization_notification_templates_list', kwargs={'pk': obj.pk}), notification_templates_any = self.reverse('api:organization_notification_templates_any_list', kwargs={'pk': obj.pk}), @@ -1345,7 +1384,7 @@ class ProjectOptionsSerializer(BaseSerializer): if scm_type: attrs.pop('local_path', None) if 'local_path' in attrs and attrs['local_path'] not in valid_local_paths: - errors['local_path'] = 'Invalid path choice.' + errors['local_path'] = _('This path is already being used by another manual project.') if errors: raise serializers.ValidationError(errors) @@ -1923,9 +1962,7 @@ class CustomInventoryScriptSerializer(BaseSerializer): class InventorySourceOptionsSerializer(BaseSerializer): - credential = models.PositiveIntegerField( - blank=True, null=True, default=None, - help_text='This resource has been deprecated and will be removed in a future release') + credential = DeprecatedCredentialField() class Meta: fields = ('*', 'source', 'source_path', 'source_script', 'source_vars', 'credential', @@ -2261,6 +2298,7 @@ class RoleSerializer(BaseSerializer): class Meta: model = Role + fields = ('*', '-created', '-modified') read_only_fields = ('id', 'role_field', 'description', 'name') def to_representation(self, obj): @@ -2276,8 +2314,6 @@ class RoleSerializer(BaseSerializer): ret['summary_fields']['resource_type'] = get_type_for_model(content_model) ret['summary_fields']['resource_type_display_name'] = content_model._meta.verbose_name.title() - ret.pop('created') - ret.pop('modified') return ret def get_related(self, obj): @@ -2465,6 +2501,9 @@ class CredentialTypeSerializer(BaseSerializer): field['label'] = _(field['label']) if 'help_text' in field: field['help_text'] = _(field['help_text']) + if field['type'] == 'become_method': + field.pop('type') + field['choices'] = map(operator.itemgetter(0), CHOICES_PRIVILEGE_ESCALATION_METHODS) return value def filter_field_metadata(self, fields, method): @@ -2634,7 +2673,9 @@ class CredentialSerializer(BaseSerializer): for field in set(data.keys()) - valid_fields - set(credential_type.defined_fields): if data.get(field): raise serializers.ValidationError( - {"detail": _("'%s' is not a valid field for %s") % (field, credential_type.name)} + {"detail": _("'{field_name}' is not a valid field for {credential_type_name}").format( + field_name=field, credential_type_name=credential_type.name + )} ) value.pop('kind', None) return value @@ -2785,15 +2826,11 @@ class V1JobOptionsSerializer(BaseSerializer): model = Credential fields = ('*', 'cloud_credential', 'network_credential') - V1_FIELDS = { - 'cloud_credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), - 'network_credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), - } + V1_FIELDS = ('cloud_credential', 'network_credential',) def build_field(self, field_name, info, model_class, nested_depth): if field_name in self.V1_FIELDS: - return self.build_standard_field(field_name, - self.V1_FIELDS[field_name]) + return (DeprecatedCredentialField, {}) return super(V1JobOptionsSerializer, self).build_field(field_name, info, model_class, nested_depth) @@ -2804,15 +2841,11 @@ class LegacyCredentialFields(BaseSerializer): model = Credential fields = ('*', 'credential', 'vault_credential') - LEGACY_FIELDS = { - 'credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), - 'vault_credential': models.PositiveIntegerField(blank=True, null=True, default=None, help_text=DEPRECATED), - } + LEGACY_FIELDS = ('credential', 'vault_credential',) def build_field(self, field_name, info, model_class, nested_depth): if field_name in self.LEGACY_FIELDS: - return self.build_standard_field(field_name, - self.LEGACY_FIELDS[field_name]) + return (DeprecatedCredentialField, {}) return super(LegacyCredentialFields, self).build_field(field_name, info, model_class, nested_depth) @@ -3045,6 +3078,11 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO inventory = get_field_from_model_or_attrs('inventory') project = get_field_from_model_or_attrs('project') + if get_field_from_model_or_attrs('host_config_key') and not inventory: + raise serializers.ValidationError({'host_config_key': _( + "Cannot enable provisioning callback without an inventory set." + )}) + prompting_error_message = _("Must either set a default value or ask to prompt on launch.") if project is None: raise serializers.ValidationError({'project': _("Job types 'run' and 'check' must have assigned a project.")}) @@ -3059,7 +3097,6 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO def get_summary_fields(self, obj): summary_fields = super(JobTemplateSerializer, self).get_summary_fields(obj) all_creds = [] - extra_creds = [] if obj.pk: for cred in obj.credentials.all(): summarized_cred = { @@ -3070,20 +3107,31 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO 'credential_type_id': cred.credential_type_id } all_creds.append(summarized_cred) - if self.is_detail_view: - for summarized_cred in all_creds: - if summarized_cred['kind'] in ('cloud', 'net'): - extra_creds.append(summarized_cred) - elif summarized_cred['kind'] == 'ssh': - summary_fields['credential'] = summarized_cred - elif summarized_cred['kind'] == 'vault': - summary_fields['vault_credential'] = summarized_cred + # Organize credential data into multitude of deprecated fields + extra_creds = [] + vault_credential = None + credential = None + for summarized_cred in all_creds: + if summarized_cred['kind'] in ('cloud', 'net'): + extra_creds.append(summarized_cred) + elif summarized_cred['kind'] == 'ssh': + credential = summarized_cred + elif summarized_cred['kind'] == 'vault': + vault_credential = summarized_cred + # Selectively apply those fields, depending on view deetails + if (self.is_detail_view or self.version == 1) and credential: + summary_fields['credential'] = credential + else: + # Credential could be an empty dictionary in this case + summary_fields.pop('credential', None) + if (self.is_detail_view or self.version == 1) and vault_credential: + summary_fields['vault_credential'] = vault_credential + else: + # vault credential could be empty dictionary + summary_fields.pop('vault_credential', None) if self.version > 1: if self.is_detail_view: summary_fields['extra_credentials'] = extra_creds - else: - # Credential would be an empty dictionary in this case - summary_fields.pop('credential', None) summary_fields['credentials'] = all_creds return summary_fields @@ -3163,7 +3211,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): data.setdefault('project', job_template.project.pk) data.setdefault('playbook', job_template.playbook) if job_template.credential: - data.setdefault('credential', job_template.credential.pk) + data.setdefault('credential', job_template.credential) data.setdefault('forks', job_template.forks) data.setdefault('limit', job_template.limit) data.setdefault('verbosity', job_template.verbosity) @@ -3210,11 +3258,12 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer): return summary_fields -class JobCancelSerializer(JobSerializer): +class JobCancelSerializer(BaseSerializer): can_cancel = serializers.BooleanField(read_only=True) class Meta: + model = Job fields = ('can_cancel',) @@ -3669,9 +3718,7 @@ class LaunchConfigurationBaseSerializer(BaseSerializer): class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): - credential = models.PositiveIntegerField( - blank=True, null=True, default=None, - help_text='This resource has been deprecated and will be removed in a future release') + credential = DeprecatedCredentialField() success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) @@ -3762,9 +3809,7 @@ class WorkflowJobTemplateNodeSerializer(LaunchConfigurationBaseSerializer): class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer): - credential = models.PositiveIntegerField( - blank=True, null=True, default=None, - help_text='This resource has been deprecated and will be removed in a future release') + credential = DeprecatedCredentialField() success_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) failure_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) always_nodes = serializers.PrimaryKeyRelatedField(many=True, read_only=True) @@ -4505,7 +4550,12 @@ class InstanceSerializer(BaseSerializer): consumed_capacity = serializers.SerializerMethodField() percent_capacity_remaining = serializers.SerializerMethodField() - jobs_running = serializers.SerializerMethodField() + jobs_running = serializers.IntegerField( + help_text=_('Count of jobs in the running or waiting state that ' + 'are targeted for this instance'), + read_only=True + ) + class Meta: model = Instance @@ -4524,14 +4574,11 @@ class InstanceSerializer(BaseSerializer): return obj.consumed_capacity def get_percent_capacity_remaining(self, obj): - if not obj.capacity or obj.consumed_capacity == obj.capacity: + if not obj.capacity or obj.consumed_capacity >= obj.capacity: return 0.0 else: return float("{0:.2f}".format(((float(obj.capacity) - float(obj.consumed_capacity)) / (float(obj.capacity))) * 100)) - def get_jobs_running(self, obj): - return UnifiedJob.objects.filter(execution_node=obj.hostname, status__in=('running', 'waiting',)).count() - class InstanceGroupSerializer(BaseSerializer): @@ -4540,6 +4587,22 @@ class InstanceGroupSerializer(BaseSerializer): percent_capacity_remaining = serializers.SerializerMethodField() jobs_running = serializers.SerializerMethodField() instances = serializers.SerializerMethodField() + # NOTE: help_text is duplicated from field definitions, no obvious way of + # both defining field details here and also getting the field's help_text + policy_instance_percentage = serializers.IntegerField( + default=0, min_value=0, max_value=100, required=False, initial=0, + help_text=_("Minimum percentage of all instances that will be automatically assigned to " + "this group when new instances come online.") + ) + policy_instance_minimum = serializers.IntegerField( + default=0, min_value=0, required=False, initial=0, + help_text=_("Static minimum number of Instances that will be automatically assign to " + "this group when new instances come online.") + ) + policy_instance_list = serializers.ListField( + child=serializers.CharField(), + help_text=_("List of exact-match Instances that will be assigned to this group") + ) class Meta: model = InstanceGroup @@ -4556,6 +4619,14 @@ class InstanceGroupSerializer(BaseSerializer): res['controller'] = self.reverse('api:instance_group_detail', kwargs={'pk': obj.controller_id}) return res + def validate_policy_instance_list(self, value): + for instance_name in value: + if value.count(instance_name) > 1: + raise serializers.ValidationError(_('Duplicate entry {}.').format(instance_name)) + if not Instance.objects.filter(hostname=instance_name).exists(): + raise serializers.ValidationError(_('{} is not a valid hostname of an existing instance.').format(instance_name)) + return value + def get_jobs_qs(self): # Store running jobs queryset in context, so it will be shared in ListView if 'running_jobs' not in self.context: @@ -4582,9 +4653,12 @@ class InstanceGroupSerializer(BaseSerializer): def get_percent_capacity_remaining(self, obj): if not obj.capacity: return 0.0 + consumed = self.get_consumed_capacity(obj) + if consumed >= obj.capacity: + return 0.0 else: return float("{0:.2f}".format( - ((float(obj.capacity) - float(self.get_consumed_capacity(obj))) / (float(obj.capacity))) * 100) + ((float(obj.capacity) - float(consumed)) / (float(obj.capacity))) * 100) ) def get_jobs_running(self, obj): @@ -4614,7 +4688,10 @@ class ActivityStreamSerializer(BaseSerializer): ('workflow_job_template_node', ('id', 'unified_job_template_id')), ('label', ('id', 'name', 'organization_id')), ('notification', ('id', 'status', 'notification_type', 'notification_template_id')), - ('access_token', ('id', 'token')) + ('o_auth2_access_token', ('id', 'user_id', 'description', 'application_id', 'scope')), + ('o_auth2_application', ('id', 'name', 'description')), + ('credential_type', ('id', 'name', 'description', 'kind', 'managed_by_tower')), + ('ad_hoc_command', ('id', 'name', 'status', 'limit')) ] return field_list @@ -4656,6 +4733,10 @@ class ActivityStreamSerializer(BaseSerializer): def get_related(self, obj): rel = {} + VIEW_NAME_EXCEPTIONS = { + 'custom_inventory_script': 'inventory_script_detail', + 'o_auth2_access_token': 'o_auth2_token_detail' + } if obj.actor is not None: rel['actor'] = self.reverse('api:user_detail', kwargs={'pk': obj.actor.pk}) for fk, __ in self._local_summarizable_fk_fields: @@ -4669,18 +4750,11 @@ class ActivityStreamSerializer(BaseSerializer): if getattr(thisItem, 'id', None) in id_list: continue id_list.append(getattr(thisItem, 'id', None)) - if fk == 'custom_inventory_script': - rel[fk].append(self.reverse('api:inventory_script_detail', kwargs={'pk': thisItem.id})) - elif fk == 'application': - rel[fk].append(self.reverse( - 'api:o_auth2_application_detail', kwargs={'pk': thisItem.pk} - )) - elif fk == 'access_token': - rel[fk].append(self.reverse( - 'api:o_auth2_token_detail', kwargs={'pk': thisItem.pk} - )) + if fk in VIEW_NAME_EXCEPTIONS: + view_name = VIEW_NAME_EXCEPTIONS[fk] else: - rel[fk].append(self.reverse('api:' + fk + '_detail', kwargs={'pk': thisItem.id})) + view_name = fk + '_detail' + rel[fk].append(self.reverse('api:' + view_name, kwargs={'pk': thisItem.id})) if fk == 'schedule': rel['unified_job_template'] = thisItem.unified_job_template.get_absolute_url(self.context.get('request')) @@ -4689,7 +4763,6 @@ class ActivityStreamSerializer(BaseSerializer): 'api:setting_singleton_detail', kwargs={'category_slug': obj.setting['category']} ) - rel['access_token'] = '*************' return rel def _get_rel(self, obj, fk): @@ -4743,7 +4816,6 @@ class ActivityStreamSerializer(BaseSerializer): last_name = obj.actor.last_name) if obj.setting: summary_fields['setting'] = [obj.setting] - summary_fields['access_token'] = '*************' return summary_fields diff --git a/awx/api/templates/api/_list_common.md b/awx/api/templates/api/_list_common.md index de58292756..bb9780ee09 100644 --- a/awx/api/templates/api/_list_common.md +++ b/awx/api/templates/api/_list_common.md @@ -60,9 +60,10 @@ _Added in AWX 1.4_ ?related__search=findme -Note: If you want to provide more than one search terms, please use multiple +Note: If you want to provide more than one search term, multiple search fields with the same key, like `?related__search=foo&related__search=bar`, -All search terms with the same key will be ORed together. +will be ORed together. Terms separated by commas, like `?related__search=foo,bar` +will be ANDed together. ## Filtering diff --git a/awx/api/templates/api/system_job_template_launch.md b/awx/api/templates/api/system_job_template_launch.md index 95d0f6b378..ee05d38cb9 100644 --- a/awx/api/templates/api/system_job_template_launch.md +++ b/awx/api/templates/api/system_job_template_launch.md @@ -12,12 +12,6 @@ For example on `cleanup_jobs` and `cleanup_activitystream`: Which will act on data older than 30 days. -For `cleanup_facts`: - -`{"extra_vars": {"older_than": "4w", "granularity": "3d"}}` - -Which will reduce the granularity of scan data to one scan per 3 days when the data is older than 4w. - For `cleanup_activitystream` and `cleanup_jobs` commands, providing `"dry_run": true` inside of `extra_vars` will show items that will be removed without deleting them. @@ -27,7 +21,6 @@ applicable either when running it from the command line or launching its system job template with empty `extra_vars`. - Defaults for `cleanup_activitystream`: days=90 - - Defaults for `cleanup_facts`: older_than="30d", granularity="1w" - Defaults for `cleanup_jobs`: days=90 If successful, the response status code will be 202. If the job cannot be diff --git a/awx/api/urls/organization.py b/awx/api/urls/organization.py index b17ffce1fa..911143bb86 100644 --- a/awx/api/urls/organization.py +++ b/awx/api/urls/organization.py @@ -21,6 +21,7 @@ from awx.api.views import ( OrganizationInstanceGroupsList, OrganizationObjectRolesList, OrganizationAccessList, + OrganizationApplicationList, ) @@ -45,6 +46,7 @@ urls = [ url(r'^(?P[0-9]+)/instance_groups/$', OrganizationInstanceGroupsList.as_view(), name='organization_instance_groups_list'), url(r'^(?P[0-9]+)/object_roles/$', OrganizationObjectRolesList.as_view(), name='organization_object_roles_list'), url(r'^(?P[0-9]+)/access_list/$', OrganizationAccessList.as_view(), name='organization_access_list'), + url(r'^(?P[0-9]+)/applications/$', OrganizationApplicationList.as_view(), name='organization_applications_list'), ] __all__ = ['urls'] diff --git a/awx/api/views.py b/awx/api/views.py index d5ae7782c6..5e080ccea9 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -77,6 +77,7 @@ from awx.main.utils import ( from awx.main.utils.encryption import encrypt_value from awx.main.utils.filters import SmartFilter from awx.main.utils.insights import filter_insights_api_response +from awx.main.redact import UriCleaner from awx.api.permissions import ( JobTemplateCallbackPermission, TaskPermission, @@ -203,6 +204,10 @@ class InstanceGroupMembershipMixin(object): class RelatedJobsPreventDeleteMixin(object): def perform_destroy(self, obj): + self.check_related_active_jobs(obj) + return super(RelatedJobsPreventDeleteMixin, self).perform_destroy(obj) + + def check_related_active_jobs(self, obj): active_jobs = obj.get_active_jobs() if len(active_jobs) > 0: raise ActiveJobConflict(active_jobs) @@ -213,7 +218,6 @@ class RelatedJobsPreventDeleteMixin(object): raise PermissionDenied(_( 'Related job {} is still processing events.' ).format(unified_job.log_format)) - return super(RelatedJobsPreventDeleteMixin, self).perform_destroy(obj) class ApiRootView(APIView): @@ -631,7 +635,7 @@ class InstanceDetail(RetrieveUpdateAPIView): class InstanceUnifiedJobsList(SubListAPIView): - view_name = _("Instance Running Jobs") + view_name = _("Instance Jobs") model = UnifiedJob serializer_class = UnifiedJobSerializer parent_model = Instance @@ -667,6 +671,14 @@ class InstanceGroupDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAP serializer_class = InstanceGroupSerializer permission_classes = (InstanceGroupTowerPermission,) + def destroy(self, request, *args, **kwargs): + instance = self.get_object() + if instance.controller is not None: + raise PermissionDenied(detail=_("Isolated Groups can not be removed from the API")) + if instance.controlled_groups.count(): + raise PermissionDenied(detail=_("Instance Groups acting as a controller for an Isolated Group can not be removed from the API")) + return super(InstanceGroupDetail, self).destroy(request, *args, **kwargs) + class InstanceGroupUnifiedJobsList(SubListAPIView): @@ -995,6 +1007,8 @@ class OrganizationInventoriesList(SubListAPIView): class BaseUsersList(SubListCreateAttachDetachAPIView): def post(self, request, *args, **kwargs): ret = super(BaseUsersList, self).post( request, *args, **kwargs) + if ret.status_code != 201: + return ret try: if ret.data is not None and request.data.get('is_system_auditor', False): # This is a faux-field that just maps to checking the system @@ -1598,6 +1612,18 @@ class UserAuthorizedTokenList(SubListCreateAPIView): def get_queryset(self): return get_access_token_model().objects.filter(application__isnull=False, user=self.request.user) + + +class OrganizationApplicationList(SubListCreateAPIView): + + view_name = _("Organization OAuth2 Applications") + + model = OAuth2Application + serializer_class = OAuth2ApplicationSerializer + parent_model = Organization + relationship = 'applications' + parent_key = 'organization' + swagger_topic = 'Authentication' class OAuth2PersonalTokenList(SubListCreateAPIView): @@ -1669,14 +1695,8 @@ class UserRolesList(SubListAttachDetachAPIView): if not sub_id: return super(UserRolesList, self).post(request) - if sub_id == self.request.user.admin_role.pk: - raise PermissionDenied(_('You may not perform any action with your own admin_role.')) - user = get_object_or_400(User, pk=self.kwargs['pk']) role = get_object_or_400(Role, pk=sub_id) - user_content_type = ContentType.objects.get_for_model(User) - if role.content_type == user_content_type: - raise PermissionDenied(_('You may not change the membership of a users admin_role')) credential_content_type = ContentType.objects.get_for_model(Credential) if role.content_type == credential_content_type: @@ -2071,6 +2091,7 @@ class InventoryDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, Retri obj = self.get_object() if not request.user.can_access(self.model, 'delete', obj): raise PermissionDenied() + self.check_related_active_jobs(obj) # related jobs mixin try: obj.schedule_deletion(getattr(request.user, 'id', None)) return Response(status=status.HTTP_202_ACCEPTED) @@ -2169,7 +2190,7 @@ class HostList(HostRelatedSearchMixin, ListCreateAPIView): return Response(dict(error=_(six.text_type(e))), status=status.HTTP_400_BAD_REQUEST) -class HostDetail(ControlledByScmMixin, RetrieveUpdateDestroyAPIView): +class HostDetail(RelatedJobsPreventDeleteMixin, ControlledByScmMixin, RetrieveUpdateDestroyAPIView): always_allow_superuser = False model = Host @@ -3116,16 +3137,22 @@ class JobTemplateSurveySpec(GenericAPIView): return Response() def _validate_spec_data(self, new_spec, old_spec): - if "name" not in new_spec: - return Response(dict(error=_("'name' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST) - if "description" not in new_spec: - return Response(dict(error=_("'description' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST) - if "spec" not in new_spec: - return Response(dict(error=_("'spec' missing from survey spec.")), status=status.HTTP_400_BAD_REQUEST) - if not isinstance(new_spec["spec"], list): - return Response(dict(error=_("'spec' must be a list of items.")), status=status.HTTP_400_BAD_REQUEST) - if len(new_spec["spec"]) < 1: - return Response(dict(error=_("'spec' doesn't contain any items.")), status=status.HTTP_400_BAD_REQUEST) + schema_errors = {} + for field, expect_type, type_label in [ + ('name', six.string_types, 'string'), + ('description', six.string_types, 'string'), + ('spec', list, 'list of items')]: + if field not in new_spec: + schema_errors['error'] = _("Field '{}' is missing from survey spec.").format(field) + elif not isinstance(new_spec[field], expect_type): + schema_errors['error'] = _("Expected {} for field '{}', received {} type.").format( + type_label, field, type(new_spec[field]).__name__) + + if isinstance(new_spec.get('spec', None), list) and len(new_spec["spec"]) < 1: + schema_errors['error'] = _("'spec' doesn't contain any items.") + + if schema_errors: + return Response(schema_errors, status=status.HTTP_400_BAD_REQUEST) variable_set = set() old_spec_dict = JobTemplate.pivot_spec(old_spec) @@ -3458,6 +3485,13 @@ class JobTemplateJobsList(SubListCreateAPIView): relationship = 'jobs' parent_key = 'job_template' + @property + def allowed_methods(self): + methods = super(JobTemplateJobsList, self).allowed_methods + if get_request_version(getattr(self, 'request', None)) > 1: + methods.remove('POST') + return methods + class JobTemplateInstanceGroupsList(SubListAttachDetachAPIView): @@ -4122,7 +4156,7 @@ class JobRelaunch(RetrieveAPIView): for p in needed_passwords: data['credential_passwords'][p] = u'' else: - data.pop('credential_passwords') + data.pop('credential_passwords', None) return data @csrf_exempt @@ -4618,9 +4652,17 @@ class UnifiedJobList(ListAPIView): serializer_class = UnifiedJobListSerializer -class StdoutANSIFilter(object): +def redact_ansi(line): + # Remove ANSI escape sequences used to embed event data. + line = re.sub(r'\x1b\[K(?:[A-Za-z0-9+/=]+\x1b\[\d+D)+\x1b\[K', '', line) + # Remove ANSI color escape sequences. + return re.sub(r'\x1b[^m]*m', '', line) + + +class StdoutFilter(object): def __init__(self, fileobj): + self._functions = [] self.fileobj = fileobj self.extra_data = '' if hasattr(fileobj, 'close'): @@ -4632,10 +4674,7 @@ class StdoutANSIFilter(object): line = self.fileobj.readline(size) if not line: break - # Remove ANSI escape sequences used to embed event data. - line = re.sub(r'\x1b\[K(?:[A-Za-z0-9+/=]+\x1b\[\d+D)+\x1b\[K', '', line) - # Remove ANSI color escape sequences. - line = re.sub(r'\x1b[^m]*m', '', line) + line = self.process_line(line) data += line if size > 0 and len(data) > size: self.extra_data = data[size:] @@ -4644,6 +4683,14 @@ class StdoutANSIFilter(object): self.extra_data = '' return data + def register(self, func): + self._functions.append(func) + + def process_line(self, line): + for func in self._functions: + line = func(line) + return line + class UnifiedJobStdout(RetrieveAPIView): @@ -4701,9 +4748,12 @@ class UnifiedJobStdout(RetrieveAPIView): suffix='.ansi' if target_format == 'ansi_download' else '' ) content_fd = unified_job.result_stdout_raw_handle(enforce_max_bytes=False) + redactor = StdoutFilter(content_fd) if target_format == 'txt_download': - content_fd = StdoutANSIFilter(content_fd) - response = HttpResponse(FileWrapper(content_fd), content_type='text/plain') + redactor.register(redact_ansi) + if type(unified_job) == ProjectUpdate: + redactor.register(UriCleaner.remove_sensitive) + response = HttpResponse(FileWrapper(redactor), content_type='text/plain') response["Content-Disposition"] = 'attachment; filename="{}"'.format(filename) return response else: @@ -4882,12 +4932,6 @@ class RoleUsersList(SubListAttachDetachAPIView): user = get_object_or_400(User, pk=sub_id) role = self.get_parent_object() - if role == self.request.user.admin_role: - raise PermissionDenied(_('You may not perform any action with your own admin_role.')) - - user_content_type = ContentType.objects.get_for_model(User) - if role.content_type == user_content_type: - raise PermissionDenied(_('You may not change the membership of a users admin_role')) credential_content_type = ContentType.objects.get_for_model(Credential) if role.content_type == credential_content_type: diff --git a/awx/lib/awx_display_callback/module.py b/awx/lib/awx_display_callback/module.py index 368063d0d1..e6895d5c89 100644 --- a/awx/lib/awx_display_callback/module.py +++ b/awx/lib/awx_display_callback/module.py @@ -28,6 +28,7 @@ import uuid from copy import copy # Ansible +from ansible import constants as C from ansible.plugins.callback import CallbackBase from ansible.plugins.callback.default import CallbackModule as DefaultCallbackModule @@ -126,16 +127,19 @@ class BaseCallbackModule(CallbackBase): task=(task.name or task.action), task_uuid=str(task._uuid), task_action=task.action, + task_args='', ) try: task_ctx['task_path'] = task.get_path() except AttributeError: pass - 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 C.DISPLAY_ARGS_TO_STDOUT: + 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): task_role = task._role._role_name else: @@ -274,15 +278,14 @@ class BaseCallbackModule(CallbackBase): with self.capture_event_data('playbook_on_no_hosts_remaining'): super(BaseCallbackModule, self).v2_playbook_on_no_hosts_remaining() - def v2_playbook_on_notify(self, result, handler): - # NOTE: Not used by Ansible 2.x. + def v2_playbook_on_notify(self, handler, host): + # NOTE: Not used by Ansible < 2.5. event_data = dict( - host=result._host.get_name(), - task=result._task, - handler=handler, + host=host.get_name(), + handler=handler.get_name(), ) with self.capture_event_data('playbook_on_notify', **event_data): - super(BaseCallbackModule, self).v2_playbook_on_notify(result, handler) + super(BaseCallbackModule, self).v2_playbook_on_notify(handler, host) ''' ansible_stats is, retoractively, added in 2.2 @@ -315,6 +318,14 @@ class BaseCallbackModule(CallbackBase): with self.capture_event_data('playbook_on_stats', **event_data): super(BaseCallbackModule, self).v2_playbook_on_stats(stats) + @staticmethod + def _get_event_loop(task): + if hasattr(task, 'loop_with'): # Ansible >=2.5 + return task.loop_with + elif hasattr(task, 'loop'): # Ansible <2.4 + return task.loop + return None + def v2_runner_on_ok(self, result): # FIXME: Display detailed results or not based on verbosity. @@ -328,7 +339,7 @@ class BaseCallbackModule(CallbackBase): remote_addr=result._host.address, task=result._task, res=result._result, - event_loop=result._task.loop if hasattr(result._task, 'loop') else None, + event_loop=self._get_event_loop(result._task), ) with self.capture_event_data('runner_on_ok', **event_data): super(BaseCallbackModule, self).v2_runner_on_ok(result) @@ -341,7 +352,7 @@ class BaseCallbackModule(CallbackBase): res=result._result, task=result._task, ignore_errors=ignore_errors, - event_loop=result._task.loop if hasattr(result._task, 'loop') else None, + event_loop=self._get_event_loop(result._task), ) with self.capture_event_data('runner_on_failed', **event_data): super(BaseCallbackModule, self).v2_runner_on_failed(result, ignore_errors) @@ -351,7 +362,7 @@ class BaseCallbackModule(CallbackBase): host=result._host.get_name(), remote_addr=result._host.address, task=result._task, - event_loop=result._task.loop if hasattr(result._task, 'loop') else None, + event_loop=self._get_event_loop(result._task), ) with self.capture_event_data('runner_on_skipped', **event_data): super(BaseCallbackModule, self).v2_runner_on_skipped(result) diff --git a/awx/lib/tests/test_display_callback.py b/awx/lib/tests/test_display_callback.py index d8c7923108..d07a6f4604 100644 --- a/awx/lib/tests/test_display_callback.py +++ b/awx/lib/tests/test_display_callback.py @@ -28,6 +28,7 @@ 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 import __version__ as ANSIBLE_VERSION from ansible.cli.playbook import PlaybookCLI from ansible.executor.playbook_executor import PlaybookExecutor from ansible.inventory.manager import InventoryManager @@ -35,7 +36,7 @@ with mock.patch.dict(os.environ, {'ANSIBLE_STDOUT_CALLBACK': CALLBACK, from ansible.vars.manager import VariableManager # Add awx/lib to sys.path so we can use the plugin - path = os.path.abspath(os.path.join(PLUGINS, '..', '..')) + path = os.path.abspath(os.path.join(PLUGINS, '..', '..', 'lib')) if path not in sys.path: sys.path.insert(0, path) @@ -176,6 +177,19 @@ def test_callback_plugin_receives_events(executor, cache, event, playbook): when: item != "SENSITIVE-SKIPPED" failed_when: item == "SENSITIVE-FAILED" ignore_errors: yes +'''}, # noqa, NOTE: with_items will be deprecated in 2.9 +{'loop.yml': ''' +- name: loop tasks should be suppressed with no_log + connection: local + hosts: all + gather_facts: no + tasks: + - shell: echo {{ item }} + no_log: true + loop: [ "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): @@ -186,14 +200,16 @@ def test_callback_plugin_no_log_filters(executor, cache, playbook): @pytest.mark.parametrize('playbook', [ {'no_log_on_ok.yml': ''' -- name: args should not be logged when task-level no_log is set +- name: args should not be logged when no_log is set at the task or module level connection: local hosts: all gather_facts: no tasks: - - shell: echo "SENSITIVE" + - shell: echo "PUBLIC" - shell: echo "PRIVATE" no_log: true + - uri: url=https://example.org username="PUBLIC" password="PRIVATE" + - copy: content="PRIVATE" dest="/tmp/tmp_no_log" '''}, # noqa ]) def test_callback_plugin_task_args_leak(executor, cache, playbook): @@ -204,15 +220,15 @@ def test_callback_plugin_task_args_leak(executor, cache, playbook): # 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 + assert 'PUBLIC' in json.dumps(cache.items()) + assert 'PRIVATE' not in json.dumps(cache.items()) + # make sure playbook was successful, so all tasks were hit + assert not events[-1]['event_data']['failures'], 'Unexpected playbook execution failure' @pytest.mark.parametrize('playbook', [ @@ -284,3 +300,54 @@ def test_callback_plugin_saves_custom_stats(executor, cache, playbook): assert json.load(f) == {'foo': 'bar'} finally: shutil.rmtree(os.path.join(private_data_dir)) + + +@pytest.mark.parametrize('playbook', [ +{'handle_playbook_on_notify.yml': ''' +- name: handle playbook_on_notify events properly + connection: local + hosts: all + handlers: + - name: my_handler + debug: msg="My Handler" + tasks: + - debug: msg="My Task" + changed_when: true + notify: + - my_handler +'''}, # noqa +]) +@pytest.mark.skipif(ANSIBLE_VERSION < '2.5', reason="v2_playbook_on_notify doesn't work before ansible 2.5") +def test_callback_plugin_records_notify_events(executor, cache, playbook): + executor.run() + assert len(cache) + notify_events = [x[1] for x in cache.items() if x[1]['event'] == 'playbook_on_notify'] + assert len(notify_events) == 1 + 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()) diff --git a/awx/main/access.py b/awx/main/access.py index bbbcd0652f..ef2577d695 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -33,8 +33,7 @@ from awx.main.models.mixins import ResourceMixin from awx.conf.license import LicenseForbids, feature_enabled __all__ = ['get_user_queryset', 'check_user_access', 'check_user_access_with_errors', - 'user_accessible_objects', 'consumer_access', - 'user_admin_role',] + 'user_accessible_objects', 'consumer_access',] logger = logging.getLogger('awx.main.access') @@ -78,18 +77,6 @@ def register_access(model_class, access_class): access_registry[model_class] = access_class -@property -def user_admin_role(self): - role = Role.objects.get( - content_type=ContentType.objects.get_for_model(User), - object_id=self.id, - role_field='admin_role' - ) - # Trick the user.admin_role so that the signal filtering for RBAC activity stream works as intended. - role.parents = [org.admin_role.pk for org in self.organizations] - return role - - def user_accessible_objects(user, role_name): return ResourceMixin._accessible_objects(User, user, role_name) @@ -344,14 +331,13 @@ class BaseAccess(object): if 'write' not in getattr(self.user, 'oauth_scopes', ['write']): user_capabilities[display_method] = False # Read tokens cannot take any actions continue - elif display_method == 'copy' and isinstance(obj, JobTemplate): + elif display_method in ['copy', 'start', 'schedule'] and isinstance(obj, JobTemplate): if obj.validation_errors: user_capabilities[display_method] = False continue - elif isinstance(obj, (WorkflowJobTemplate, WorkflowJob)): - if not feature_enabled('workflows'): - user_capabilities[display_method] = (display_method == 'delete') - continue + elif isinstance(obj, (WorkflowJobTemplate, WorkflowJob)) and (not feature_enabled('workflows')): + user_capabilities[display_method] = (display_method == 'delete') + continue elif display_method == 'copy' and isinstance(obj, WorkflowJobTemplate) and obj.organization_id is None: user_capabilities[display_method] = self.user.is_superuser continue @@ -395,7 +381,7 @@ class BaseAccess(object): elif display_method == 'delete' and not isinstance(obj, (User, UnifiedJob, CustomInventoryScript)): user_capabilities['delete'] = user_capabilities['edit'] continue - elif display_method == 'copy' and isinstance(obj, (Group, Host, CustomInventoryScript)): + elif display_method == 'copy' and isinstance(obj, (Group, Host)): user_capabilities['copy'] = user_capabilities['edit'] continue @@ -469,15 +455,6 @@ class InstanceGroupAccess(BaseAccess): def can_change(self, obj, data): return self.user.is_superuser - def can_delete(self, obj): - return self.user.is_superuser - - def can_attach(self, obj, sub_obj, relationship, *args, **kwargs): - return self.user.is_superuser - - def can_unattach(self, obj, sub_obj, relationship, *args, **kwargs): - return self.user.is_superuser - class UserAccess(BaseAccess): ''' @@ -539,12 +516,42 @@ class UserAccess(BaseAccess): return False return bool(self.user == obj or self.can_admin(obj, data)) + def user_membership_roles(self, u): + return Role.objects.filter( + content_type=ContentType.objects.get_for_model(Organization), + role_field__in=[ + 'admin_role', 'member_role', + 'execute_role', 'project_admin_role', 'inventory_admin_role', + 'credential_admin_role', 'workflow_admin_role', + 'notification_admin_role' + ], + members=u + ) + + def is_all_org_admin(self, u): + return not self.user_membership_roles(u).exclude( + ancestors__in=self.user.roles.filter(role_field='admin_role') + ).exists() + + def user_is_orphaned(self, u): + return not self.user_membership_roles(u).exists() + @check_superuser - def can_admin(self, obj, data): + def can_admin(self, obj, data, allow_orphans=False): if not settings.MANAGE_ORGANIZATION_AUTH: return False - return Organization.objects.filter(Q(member_role__members=obj) | Q(admin_role__members=obj), - Q(admin_role__members=self.user)).exists() + if obj.is_superuser or obj.is_system_auditor: + # must be superuser to admin users with system roles + return False + if self.user_is_orphaned(obj): + if not allow_orphans: + # in these cases only superusers can modify orphan users + return False + return not obj.roles.all().exclude( + content_type=ContentType.objects.get_for_model(User) + ).filter(ancestors__in=self.user.roles.all()).exists() + else: + return self.is_all_org_admin(obj) def can_delete(self, obj): if obj == self.user: @@ -580,69 +587,77 @@ class UserAccess(BaseAccess): class OAuth2ApplicationAccess(BaseAccess): ''' - I can read, change or delete OAuth applications when: + I can read, change or delete OAuth 2 applications when: - I am a superuser. - I am the admin of the organization of the user of the application. - - I am the user of the application. - I can create OAuth applications when: + - I am a user in the organization of the application. + I can create OAuth 2 applications when: - I am a superuser. - - I am the admin of the organization of the user of the application. + - I am the admin of the organization of the application. ''' model = OAuth2Application select_related = ('user',) def filtered_queryset(self): - accessible_users = User.objects.filter( - pk__in=self.user.admin_of_organizations.values('member_role__members') - ) | User.objects.filter(pk=self.user.pk) - return self.model.objects.filter(user__in=accessible_users) + return self.model.objects.filter(organization__in=self.user.organizations) def can_change(self, obj, data): - return self.can_read(obj) + return self.user.is_superuser or self.check_related('organization', Organization, data, obj=obj, + role_field='admin_role', mandatory=True) def can_delete(self, obj): - return self.can_read(obj) + return self.user.is_superuser or obj.organization in self.user.admin_of_organizations def can_add(self, data): if self.user.is_superuser: - return True - user = get_object_from_data('user', User, data) - if not user: - return False - return set(self.user.admin_of_organizations.all()) & set(user.organizations.all()) + return True + if not data: + return Organization.accessible_objects(self.user, 'admin_role').exists() + return self.check_related('organization', Organization, data, role_field='admin_role', mandatory=True) class OAuth2TokenAccess(BaseAccess): ''' - I can read, change or delete an OAuth2 token when: + I can read, change or delete an app token when: - I am a superuser. - - I am the admin of the organization of the user of the token. + - I am the admin of the organization of the application of the token. - I am the user of the token. - I can create an OAuth token when: + I can create an OAuth2 app token when: - I have the read permission of the related application. + I can read, change or delete a personal token when: + - I am the user of the token + - I am the superuser + I can create an OAuth2 Personal Access Token when: + - I am a user. But I can only create a PAT for myself. ''' model = OAuth2AccessToken + select_related = ('user', 'application') - - def filtered_queryset(self): - accessible_users = User.objects.filter( - pk__in=self.user.admin_of_organizations.values('member_role__members') - ) | User.objects.filter(pk=self.user.pk) - return self.model.objects.filter(user__in=accessible_users) - - def can_change(self, obj, data): - return self.can_read(obj) - + + def filtered_queryset(self): + org_access_qs = Organization.objects.filter( + Q(admin_role__members=self.user) | Q(auditor_role__members=self.user)) + return self.model.objects.filter(application__organization__in=org_access_qs) | self.model.objects.filter(user__id=self.user.pk) + def can_delete(self, obj): - return self.can_read(obj) + if (self.user.is_superuser) | (obj.user == self.user): + return True + elif not obj.application: + return False + return self.user in obj.application.organization.admin_role + + def can_change(self, obj, data): + return self.can_delete(obj) def can_add(self, data): - app = get_object_from_data('application', OAuth2Application, data) - if not app: - return True - return OAuth2ApplicationAccess(self.user).can_read(app) + if 'application' in data: + app = get_object_from_data('application', OAuth2Application, data) + if app is None: + return True + return OAuth2ApplicationAccess(self.user).can_read(app) + return True class OrganizationAccess(BaseAccess): @@ -1450,24 +1465,7 @@ class JobAccess(BaseAccess): if not data: # So the browseable API will work return True - if not self.user.is_superuser: - return False - - - add_data = dict(data.items()) - - # If a job template is provided, the user should have read access to it. - if data and data.get('job_template', None): - job_template = get_object_from_data('job_template', JobTemplate, data) - add_data.setdefault('inventory', job_template.inventory.pk) - add_data.setdefault('project', job_template.project.pk) - add_data.setdefault('job_type', job_template.job_type) - if job_template.credential: - add_data.setdefault('credential', job_template.credential.pk) - else: - job_template = None - - return True + return self.user.is_superuser def can_change(self, obj, data): return (obj.status == 'new' and @@ -1861,7 +1859,7 @@ class WorkflowJobTemplateAccess(BaseAccess): if self.user.is_superuser: return True - return (self.check_related('organization', Organization, data, role_field='workflow_admin_field', obj=obj) and + return (self.check_related('organization', Organization, data, role_field='workflow_admin_role', obj=obj) and self.user in obj.admin_role) def can_delete(self, obj): @@ -2080,7 +2078,7 @@ class ProjectUpdateEventAccess(BaseAccess): def filtered_queryset(self): return self.model.objects.filter( - Q(project_update__in=ProjectUpdate.accessible_pk_qs(self.user, 'read_role'))) + Q(project_update__project__in=Project.accessible_pk_qs(self.user, 'read_role'))) def can_add(self, data): return False @@ -2101,7 +2099,7 @@ class InventoryUpdateEventAccess(BaseAccess): def filtered_queryset(self): return self.model.objects.filter( - Q(inventory_update__in=InventoryUpdate.accessible_pk_qs(self.user, 'read_role'))) + Q(inventory_update__inventory_source__inventory__in=Inventory.accessible_pk_qs(self.user, 'read_role'))) def can_add(self, data): return False @@ -2375,7 +2373,7 @@ class ActivityStreamAccess(BaseAccess): model = ActivityStream prefetch_related = ('organization', 'user', 'inventory', 'host', 'group', 'inventory_update', 'credential', 'credential_type', 'team', - 'ad_hoc_command', + 'ad_hoc_command', 'o_auth2_application', 'o_auth2_access_token', 'notification_template', 'notification', 'label', 'role', 'actor', 'schedule', 'custom_inventory_script', 'unified_job_template', 'workflow_job_template_node',) @@ -2418,9 +2416,13 @@ class ActivityStreamAccess(BaseAccess): jt_set = JobTemplate.accessible_objects(self.user, 'read_role') team_set = Team.accessible_objects(self.user, 'read_role') wfjt_set = WorkflowJobTemplate.accessible_objects(self.user, 'read_role') + app_set = OAuth2ApplicationAccess(self.user).filtered_queryset() + token_set = OAuth2TokenAccess(self.user).filtered_queryset() return qs.filter( Q(ad_hoc_command__inventory__in=inventory_set) | + Q(o_auth2_application__in=app_set) | + Q(o_auth2_access_token__in=token_set) | Q(user__in=auditing_orgs.values('member_role__members')) | Q(user=self.user) | Q(organization__in=auditing_orgs) | @@ -2523,6 +2525,14 @@ class RoleAccess(BaseAccess): if not check_user_access(self.user, sub_obj_resource.__class__, 'read', sub_obj_resource): return False + # Being a user in the member_role or admin_role of an organization grants + # administrators of that Organization the ability to edit that user. To prevent + # unwanted escalations lets ensure that the Organization administartor has the abilty + # to admin the user being added to the role. + if isinstance(obj.content_object, Organization) and obj.role_field in ['member_role', 'admin_role']: + if not UserAccess(self.user).can_admin(sub_obj, None, allow_orphans=True): + return False + if isinstance(obj.content_object, ResourceMixin) and \ self.user in obj.content_object.admin_role: return True diff --git a/awx/main/conf.py b/awx/main/conf.py index 85aa227722..e635fbc08d 100644 --- a/awx/main/conf.py +++ b/awx/main/conf.py @@ -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, @@ -341,7 +362,8 @@ register( label=_('Per-Host Ansible Fact Cache Timeout'), help_text=_('Maximum time, in seconds, that stored Ansible facts are considered valid since ' 'the last time they were modified. Only valid, non-stale, facts will be accessible by ' - 'a playbook. Note, this does not influence the deletion of ansible_facts from the database.'), + 'a playbook. Note, this does not influence the deletion of ansible_facts from the database. ' + 'Use a value of 0 to indicate that no timeout should be imposed.'), category=_('Jobs'), category_slug='jobs', ) diff --git a/awx/main/constants.py b/awx/main/constants.py index 447fed5ae6..e7c8a943fc 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -15,7 +15,11 @@ CLOUD_PROVIDERS = ('azure_rm', 'ec2', 'gce', 'vmware', 'openstack', 'rhv', 'sate SCHEDULEABLE_PROVIDERS = CLOUD_PROVIDERS + ('custom', 'scm',) PRIVILEGE_ESCALATION_METHODS = [ ('sudo', _('Sudo')), ('su', _('Su')), ('pbrun', _('Pbrun')), ('pfexec', _('Pfexec')), - ('dzdo', _('DZDO')), ('pmrun', _('Pmrun')), ('runas', _('Runas'))] + ('dzdo', _('DZDO')), ('pmrun', _('Pmrun')), ('runas', _('Runas')), + ('enable', _('Enable')), ('doas', _('Doas')), +] +CHOICES_PRIVILEGE_ESCALATION_METHODS = [('', _('None'))] + PRIVILEGE_ESCALATION_METHODS ANSI_SGR_PATTERN = re.compile(r'\x1b\[[0-9;]*m') CAN_CANCEL = ('new', 'pending', 'waiting', 'running') ACTIVE_STATES = CAN_CANCEL +TOKEN_CENSOR = '************' diff --git a/awx/main/exceptions.py b/awx/main/exceptions.py index 9b3ee247e1..5514934847 100644 --- a/awx/main/exceptions.py +++ b/awx/main/exceptions.py @@ -1,6 +1,9 @@ # Copyright (c) 2018 Ansible by Red Hat # All Rights Reserved. +import six + + # Celery does not respect exception type when using a serializer different than pickle; # and awx uses the json serializer # https://github.com/celery/celery/issues/3586 @@ -9,7 +12,7 @@ class _AwxTaskError(): def build_exception(self, task, message=None): if message is None: - message = "Execution error running {}".format(task.log_format) + message = six.text_type("Execution error running {}").format(task.log_format) e = Exception(message) e.task = task e.is_awx_task_error = True @@ -17,7 +20,7 @@ class _AwxTaskError(): def TaskCancel(self, task, rc): """Canceled flag caused run_pexpect to kill the job run""" - message="{} was canceled (rc={})".format(task.log_format, rc) + message=six.text_type("{} was canceled (rc={})").format(task.log_format, rc) e = self.build_exception(task, message) e.rc = rc e.awx_task_error_type = "TaskCancel" @@ -25,7 +28,7 @@ class _AwxTaskError(): def TaskError(self, task, rc): """Userspace error (non-zero exit code) in run_pexpect subprocess""" - message = "{} encountered an error (rc={}), please see task stdout for details.".format(task.log_format, rc) + message = six.text_type("{} encountered an error (rc={}), please see task stdout for details.").format(task.log_format, rc) e = self.build_exception(task, message) e.rc = rc e.awx_task_error_type = "TaskError" diff --git a/awx/main/expect/run.py b/awx/main/expect/run.py index ce685325c7..0c8881a85c 100755 --- a/awx/main/expect/run.py +++ b/awx/main/expect/run.py @@ -101,7 +101,7 @@ def run_pexpect(args, cwd, env, logfile, child = pexpect.spawn( args[0], args[1:], cwd=cwd, env=env, ignore_sighup=True, - encoding='utf-8', echo=False, + encoding='utf-8', echo=False, use_poll=True ) child.logfile_read = logfile canceled = False diff --git a/awx/main/fields.py b/awx/main/fields.py index d0f6081693..14e1cc6ad0 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -4,12 +4,13 @@ # Python import copy import json +import operator import re import six import urllib from jinja2 import Environment, StrictUndefined -from jinja2.exceptions import UndefinedError +from jinja2.exceptions import UndefinedError, TemplateSyntaxError # Django from django.core import exceptions as django_exceptions @@ -42,19 +43,24 @@ from rest_framework import serializers # AWX from awx.main.utils.filters import SmartFilter +from awx.main.utils.encryption import encrypt_value, decrypt_value, get_encryption_key from awx.main.validators import validate_ssh_private_key from awx.main.models.rbac import batch_role_ancestor_rebuilding, Role +from awx.main.constants import CHOICES_PRIVILEGE_ESCALATION_METHODS from awx.main import utils -__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField', 'SmartFilterField'] +__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField', + 'SmartFilterField', 'update_role_parentage_for_instance', + 'is_implicit_parent'] # Provide a (better) custom error message for enum jsonschema validation def __enum_validate__(validator, enums, instance, schema): if instance not in enums: yield jsonschema.exceptions.ValidationError( - _("'%s' is not one of ['%s']") % (instance, "', '".join(enums)) + _("'{value}' is not one of ['{allowed_values}']").format( + value=instance, allowed_values="', '".join(enums)) ) @@ -180,6 +186,23 @@ def is_implicit_parent(parent_role, child_role): return False +def update_role_parentage_for_instance(instance): + '''update_role_parentage_for_instance + updates the parents listing for all the roles + of a given instance if they have changed + ''' + for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): + cur_role = getattr(instance, implicit_role_field.name) + new_parents = implicit_role_field._resolve_parent_roles(instance) + cur_role.parents.set(new_parents) + new_parents_list = list(new_parents) + new_parents_list.sort() + new_parents_json = json.dumps(new_parents_list) + if cur_role.implicit_parents != new_parents_json: + cur_role.implicit_parents = new_parents_json + cur_role.save() + + class ImplicitRoleDescriptor(ForwardManyToOneDescriptor): pass @@ -273,43 +296,37 @@ class ImplicitRoleField(models.ForeignKey): Role_ = utils.get_current_apps().get_model('main', 'Role') ContentType_ = utils.get_current_apps().get_model('contenttypes', 'ContentType') ct_id = ContentType_.objects.get_for_model(instance).id + + Model = utils.get_current_apps().get_model('main', instance.__class__.__name__) + latest_instance = Model.objects.get(pk=instance.pk) + with batch_role_ancestor_rebuilding(): # Create any missing role objects missing_roles = [] - for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): - cur_role = getattr(instance, implicit_role_field.name, None) + for implicit_role_field in getattr(latest_instance.__class__, '__implicit_role_fields'): + cur_role = getattr(latest_instance, implicit_role_field.name, None) if cur_role is None: missing_roles.append( Role_( role_field=implicit_role_field.name, content_type_id=ct_id, - object_id=instance.id + object_id=latest_instance.id ) ) + if len(missing_roles) > 0: Role_.objects.bulk_create(missing_roles) updates = {} role_ids = [] - for role in Role_.objects.filter(content_type_id=ct_id, object_id=instance.id): - setattr(instance, role.role_field, role) + for role in Role_.objects.filter(content_type_id=ct_id, object_id=latest_instance.id): + setattr(latest_instance, role.role_field, role) updates[role.role_field] = role.id role_ids.append(role.id) - type(instance).objects.filter(pk=instance.pk).update(**updates) + type(latest_instance).objects.filter(pk=latest_instance.pk).update(**updates) Role.rebuild_role_ancestor_list(role_ids, []) - # Update parentage if necessary - for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): - cur_role = getattr(instance, implicit_role_field.name) - original_parents = set(json.loads(cur_role.implicit_parents)) - new_parents = implicit_role_field._resolve_parent_roles(instance) - cur_role.parents.remove(*list(original_parents - new_parents)) - cur_role.parents.add(*list(new_parents - original_parents)) - new_parents_list = list(new_parents) - new_parents_list.sort() - new_parents_json = json.dumps(new_parents_list) - if cur_role.implicit_parents != new_parents_json: - cur_role.implicit_parents = new_parents_json - cur_role.save() + update_role_parentage_for_instance(latest_instance) + instance.refresh_from_db() def _resolve_parent_roles(self, instance): @@ -391,7 +408,25 @@ class JSONSchemaField(JSONBField): error.message = re.sub(r'\bu(\'|")', r'\1', error.message) if error.validator == 'pattern' and 'error' in error.schema: - error.message = error.schema['error'] % error.instance + error.message = six.text_type(error.schema['error']).format(instance=error.instance) + elif error.validator == 'type': + expected_type = error.validator_value + if expected_type == 'object': + expected_type = 'dict' + if error.path: + error.message = _( + '{type} provided in relative path {path}, expected {expected_type}' + ).format(path=list(error.path), type=type(error.instance).__name__, + expected_type=expected_type) + else: + error.message = _( + '{type} provided, expected {expected_type}' + ).format(path=list(error.path), type=type(error.instance).__name__, + expected_type=expected_type) + elif error.validator == 'additionalProperties' and hasattr(error, 'path'): + error.message = _( + 'Schema validation error in relative path {path} ({error})' + ).format(path=list(error.path), error=error.message) errors.append(error) if errors: @@ -474,6 +509,9 @@ class CredentialInputField(JSONSchemaField): properties = {} for field in model_instance.credential_type.inputs.get('fields', []): field = field.copy() + if field['type'] == 'become_method': + field.pop('type') + field['choices'] = map(operator.itemgetter(0), CHOICES_PRIVILEGE_ESCALATION_METHODS) properties[field['id']] = field if field.get('choices', []): field['enum'] = field['choices'][:] @@ -523,7 +561,7 @@ class CredentialInputField(JSONSchemaField): format_checker=self.format_checker ).iter_errors(decrypted_values): if error.validator == 'pattern' and 'error' in error.schema: - error.message = error.schema['error'] % error.instance + error.message = six.text_type(error.schema['error']).format(instance=error.instance) if error.validator == 'dependencies': # replace the default error messaging w/ a better i18n string # I wish there was a better way to determine the parameters of @@ -617,7 +655,7 @@ class CredentialTypeInputField(JSONSchemaField): 'items': { 'type': 'object', 'properties': { - 'type': {'enum': ['string', 'boolean']}, + 'type': {'enum': ['string', 'boolean', 'become_method']}, 'format': {'enum': ['ssh_private_key']}, 'choices': { 'type': 'array', @@ -628,7 +666,7 @@ class CredentialTypeInputField(JSONSchemaField): 'id': { 'type': 'string', 'pattern': '^[a-zA-Z_]+[a-zA-Z0-9_]*$', - 'error': '%s is an invalid variable name', + 'error': '{instance} is an invalid variable name', }, 'label': {'type': 'string'}, 'help_text': {'type': 'string'}, @@ -678,10 +716,22 @@ class CredentialTypeInputField(JSONSchemaField): # If no type is specified, default to string field['type'] = 'string' + if field['type'] == 'become_method': + if not model_instance.managed_by_tower: + raise django_exceptions.ValidationError( + _('become_method is a reserved type name'), + code='invalid', + params={'value': value}, + ) + else: + field.pop('type') + field['choices'] = CHOICES_PRIVILEGE_ESCALATION_METHODS + for key in ('choices', 'multiline', 'format', 'secret',): if key in field and field['type'] != 'string': raise django_exceptions.ValidationError( - _('%s not allowed for %s type (%s)' % (key, field['type'], field['id'])), + _('{sub_key} not allowed for {element_type} type ({element_id})'.format( + sub_key=key, element_type=field['type'], element_id=field['id'])), code='invalid', params={'value': value}, ) @@ -778,7 +828,15 @@ class CredentialTypeInjectorField(JSONSchemaField): ).from_string(tmpl).render(valid_namespace) except UndefinedError as e: raise django_exceptions.ValidationError( - _('%s uses an undefined field (%s)') % (key, e), + _('{sub_key} uses an undefined field ({error_msg})').format( + sub_key=key, error_msg=e), + code='invalid', + params={'value': value}, + ) + except TemplateSyntaxError as e: + raise django_exceptions.ValidationError( + _('Syntax error rendering template for {sub_key} inside of {type} ({error_msg})').format( + sub_key=key, type=type_, error_msg=e), code='invalid', params={'value': value}, ) @@ -801,3 +859,16 @@ class AskForField(models.BooleanField): # self.name will be set by the model metaclass, not this field raise Exception('Corresponding allows_field cannot be accessed until model is initialized.') return self._allows_field + + +class OAuth2ClientSecretField(models.CharField): + + def get_db_prep_value(self, value, connection, prepared=False): + return super(OAuth2ClientSecretField, self).get_db_prep_value( + encrypt_value(value), connection, prepared + ) + + def from_db_value(self, value, expression, connection, context): + if value and value.startswith('$encrypted$'): + return decrypt_value(get_encryption_key('value', pk=None), value) + return value diff --git a/awx/main/management/commands/inventory_import.py b/awx/main/management/commands/inventory_import.py index 10ae94062f..3a03e66c53 100644 --- a/awx/main/management/commands/inventory_import.py +++ b/awx/main/management/commands/inventory_import.py @@ -403,9 +403,7 @@ class Command(BaseCommand): _eager_fields=dict( job_args=json.dumps(sys.argv), job_env=dict(os.environ.items()), - job_cwd=os.getcwd(), - execution_node=settings.CLUSTER_HOST_ID, - instance_group=InstanceGroup.objects.get(name='tower')) + job_cwd=os.getcwd()) ) # FIXME: Wait or raise error if inventory is being updated by another diff --git a/awx/main/management/commands/provision_instance.py b/awx/main/management/commands/provision_instance.py index 4b2ef8f220..e9458ac120 100644 --- a/awx/main/management/commands/provision_instance.py +++ b/awx/main/management/commands/provision_instance.py @@ -2,7 +2,6 @@ # All Rights Reserved from awx.main.models import Instance -from awx.main.utils.pglock import advisory_lock from django.conf import settings from django.db import transaction @@ -27,15 +26,12 @@ class Command(BaseCommand): def _register_hostname(self, hostname): if not hostname: return - with advisory_lock('instance_registration_%s' % hostname): - instance = Instance.objects.filter(hostname=hostname) - if instance.exists(): - print("Instance already registered {}".format(instance[0].hostname)) - return - instance = Instance(uuid=self.uuid, hostname=hostname) - instance.save() - print('Successfully registered instance {}'.format(hostname)) - self.changed = True + (changed, instance) = Instance.objects.register(uuid=self.uuid, hostname=hostname) + if changed: + print('Successfully registered instance {}'.format(hostname)) + else: + print("Instance already registered {}".format(instance.hostname)) + self.changed = changed @transaction.atomic def handle(self, **options): diff --git a/awx/main/management/commands/register_queue.py b/awx/main/management/commands/register_queue.py index 1e7912836d..85c7842381 100644 --- a/awx/main/management/commands/register_queue.py +++ b/awx/main/management/commands/register_queue.py @@ -1,6 +1,7 @@ # Copyright (c) 2017 Ansible Tower by Red Hat # All Rights Reserved. import sys +import six from awx.main.utils.pglock import advisory_lock from awx.main.models import Instance, InstanceGroup @@ -8,6 +9,13 @@ from awx.main.models import Instance, InstanceGroup from django.core.management.base import BaseCommand, CommandError +class InstanceNotFound(Exception): + def __init__(self, message, changed, *args, **kwargs): + self.message = message + self.changed = changed + super(InstanceNotFound, self).__init__(*args, **kwargs) + + class Command(BaseCommand): def add_arguments(self, parser): @@ -22,51 +30,95 @@ class Command(BaseCommand): parser.add_argument('--instance_minimum', dest='instance_minimum', type=int, default=0, help='The minimum number of instance that will be retained for this group from available instances') + + def get_create_update_instance_group(self, queuename, instance_percent, instance_min): + ig = InstanceGroup.objects.filter(name=queuename) + created = False + changed = False + + (ig, created) = InstanceGroup.objects.get_or_create(name=queuename) + if ig.policy_instance_percentage != instance_percent: + ig.policy_instance_percentage = instance_percent + changed = True + if ig.policy_instance_minimum != instance_min: + ig.policy_instance_minimum = instance_min + changed = True + + return (ig, created, changed) + + def update_instance_group_controller(self, ig, controller): + changed = False + control_ig = None + + if controller: + control_ig = InstanceGroup.objects.filter(name=controller).first() + + if control_ig and ig.controller_id != control_ig.pk: + ig.controller = control_ig + ig.save() + changed = True + + return (control_ig, changed) + + def add_instances_to_group(self, ig, hostname_list): + changed = False + + instance_list_unique = set([x.strip() for x in hostname_list if x]) + instances = [] + for inst_name in instance_list_unique: + instance = Instance.objects.filter(hostname=inst_name) + if instance.exists(): + instances.append(instance[0]) + else: + raise InstanceNotFound(six.text_type("Instance does not exist: {}").format(inst_name), changed) + + ig.instances = instances + + instance_list_before = set(ig.policy_instance_list) + instance_list_after = set(instance_list_unique) + if len(instance_list_before) != len(instance_list_after) or \ + len(set(instance_list_before) - set(instance_list_after)) != 0: + changed = True + + ig.policy_instance_list = list(instance_list_unique) + ig.save() + return (instances, changed) + def handle(self, **options): + instance_not_found_err = None queuename = options.get('queuename') if not queuename: raise CommandError("Specify `--queuename` to use this command.") - changed = False + ctrl = options.get('controller') + inst_per = options.get('instance_percent') + inst_min = options.get('instance_minimum') + hostname_list = [] + if options.get('hostnames'): + hostname_list = options.get('hostnames').split(",") + with advisory_lock('instance_group_registration_%s' % queuename): - ig = InstanceGroup.objects.filter(name=queuename) - control_ig = None - if options.get('controller'): - control_ig = InstanceGroup.objects.filter(name=options.get('controller')).first() - if ig.exists(): - print("Instance Group already registered {}".format(ig[0].name)) - ig = ig[0] - if control_ig and ig.controller_id != control_ig.pk: - ig.controller = control_ig - ig.save() - print("Set controller group {} on {}.".format(control_ig.name, ig.name)) - changed = True - else: - print("Creating instance group {}".format(queuename)) - ig = InstanceGroup(name=queuename, - policy_instance_percentage=options.get('instance_percent'), - policy_instance_minimum=options.get('instance_minimum')) - if control_ig: - ig.controller = control_ig - ig.save() - changed = True - hostname_list = [] - if options.get('hostnames'): - hostname_list = options.get('hostnames').split(",") - instance_list = [x.strip() for x in hostname_list if x] - for inst_name in instance_list: - instance = Instance.objects.filter(hostname=inst_name) - if instance.exists() and instance[0] not in ig.instances.all(): - ig.instances.add(instance[0]) - print("Added instance {} to {}".format(instance[0].hostname, ig.name)) - changed = True - elif not instance.exists(): - print("Instance does not exist: {}".format(inst_name)) - if changed: - print('(changed: True)') - sys.exit(1) - else: - print("Instance already registered {}".format(instance[0].hostname)) - ig.policy_instance_list = instance_list - ig.save() - if changed: - print('(changed: True)') + (ig, created, changed) = self.get_create_update_instance_group(queuename, inst_per, inst_min) + if created: + print(six.text_type("Creating instance group {}".format(ig.name))) + elif not created: + print(six.text_type("Instance Group already registered {}").format(ig.name)) + + if ctrl: + (ig_ctrl, changed) = self.update_instance_group_controller(ig, ctrl) + if changed: + print(six.text_type("Set controller group {} on {}.").format(ctrl, queuename)) + + try: + (instances, changed) = self.add_instances_to_group(ig, hostname_list) + for i in instances: + print(six.text_type("Added instance {} to {}").format(i.hostname, ig.name)) + except InstanceNotFound as e: + instance_not_found_err = e + + if changed: + print('(changed: True)') + + if instance_not_found_err: + print(instance_not_found_err.message) + sys.exit(1) + diff --git a/awx/main/managers.py b/awx/main/managers.py index 70c402f672..274a0ef774 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -8,6 +8,7 @@ from django.db import models from django.conf import settings from awx.main.utils.filters import SmartFilter +from awx.main.utils.pglock import advisory_lock ___all__ = ['HostManager', 'InstanceManager', 'InstanceGroupManager'] @@ -86,6 +87,24 @@ class InstanceManager(models.Manager): return node[0] raise RuntimeError("No instance found with the current cluster host id") + def register(self, uuid=None, hostname=None): + if not uuid: + uuid = settings.SYSTEM_UUID + if not hostname: + hostname = settings.CLUSTER_HOST_ID + with advisory_lock('instance_registration_%s' % hostname): + instance = self.filter(hostname=hostname) + if instance.exists(): + return (False, instance[0]) + instance = self.create(uuid=uuid, hostname=hostname) + return (True, instance) + + def get_or_register(self): + if settings.AWX_AUTO_DEPROVISION_INSTANCES: + return self.register() + else: + return (False, self.me()) + def active_count(self): """Return count of active Tower nodes for licensing.""" return self.all().count() @@ -94,6 +113,9 @@ class InstanceManager(models.Manager): # NOTE: TODO: Likely to repurpose this once standalone ramparts are a thing return "tower" + def all_non_isolated(self): + return self.exclude(rampart_groups__controller__isnull=False) + class InstanceGroupManager(models.Manager): """A custom manager class for the Instance model. @@ -156,8 +178,6 @@ class InstanceGroupManager(models.Manager): if t.status == 'waiting' or not t.execution_node: # Subtract capacity from any peer groups that share instances if not t.instance_group: - logger.warning('Excluded %s from capacity algorithm ' - '(missing instance_group).', t.log_format) impacted_groups = [] elif t.instance_group.name not in ig_ig_mapping: # Waiting job in group with 0 capacity has no collateral impact diff --git a/awx/main/migrations/0024_v330_add_oauth_activity_stream_registrar.py b/awx/main/migrations/0025_v330_add_oauth_activity_stream_registrar.py similarity index 100% rename from awx/main/migrations/0024_v330_add_oauth_activity_stream_registrar.py rename to awx/main/migrations/0025_v330_add_oauth_activity_stream_registrar.py diff --git a/awx/main/migrations/0025_v330_delete_authtoken.py b/awx/main/migrations/0026_v330_delete_authtoken.py similarity index 76% rename from awx/main/migrations/0025_v330_delete_authtoken.py rename to awx/main/migrations/0026_v330_delete_authtoken.py index cd55a901b1..c1a8fe19d4 100644 --- a/awx/main/migrations/0025_v330_delete_authtoken.py +++ b/awx/main/migrations/0026_v330_delete_authtoken.py @@ -7,11 +7,12 @@ from django.conf import settings from django.db import migrations, models import django.db.models.deletion +# TODO: Squash all of these migrations with '0024_v330_add_oauth_activity_stream_registrar' class Migration(migrations.Migration): dependencies = [ - ('main', '0024_v330_add_oauth_activity_stream_registrar'), + ('main', '0025_v330_add_oauth_activity_stream_registrar'), ] operations = [ diff --git a/awx/main/migrations/0026_v330_emitted_events.py b/awx/main/migrations/0027_v330_emitted_events.py similarity index 90% rename from awx/main/migrations/0026_v330_emitted_events.py rename to awx/main/migrations/0027_v330_emitted_events.py index cfd995c751..5c87239cd3 100644 --- a/awx/main/migrations/0026_v330_emitted_events.py +++ b/awx/main/migrations/0027_v330_emitted_events.py @@ -8,7 +8,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0025_v330_delete_authtoken'), + ('main', '0026_v330_delete_authtoken'), ] operations = [ diff --git a/awx/main/migrations/0027_v330_add_tower_verify.py b/awx/main/migrations/0028_v330_add_tower_verify.py similarity index 88% rename from awx/main/migrations/0027_v330_add_tower_verify.py rename to awx/main/migrations/0028_v330_add_tower_verify.py index 7ec04d2745..5fd671cde5 100644 --- a/awx/main/migrations/0027_v330_add_tower_verify.py +++ b/awx/main/migrations/0028_v330_add_tower_verify.py @@ -10,7 +10,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('main', '0026_v330_emitted_events'), + ('main', '0027_v330_emitted_events'), ] operations = [ diff --git a/awx/main/migrations/0030_v330_modify_application.py b/awx/main/migrations/0030_v330_modify_application.py new file mode 100644 index 0000000000..7725ffeaff --- /dev/null +++ b/awx/main/migrations/0030_v330_modify_application.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-03-16 20:25 +from __future__ import unicode_literals + +import awx.main.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0028_v330_add_tower_verify'), + ] + + operations = [ + migrations.AddField( + model_name='oauth2application', + name='organization', + field=models.ForeignKey(help_text='Organization containing this application.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='main.Organization'), + ), + ] diff --git a/awx/main/migrations/0031_v330_encrypt_oauth2_secret.py b/awx/main/migrations/0031_v330_encrypt_oauth2_secret.py new file mode 100644 index 0000000000..4bb993f423 --- /dev/null +++ b/awx/main/migrations/0031_v330_encrypt_oauth2_secret.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-04-03 20:48 +from __future__ import unicode_literals + +import awx.main.fields +from django.db import migrations +import oauth2_provider.generators + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0030_v330_modify_application'), + ] + + operations = [ + migrations.AlterField( + model_name='oauth2application', + name='client_secret', + field=awx.main.fields.OAuth2ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=1024), + ), + ] diff --git a/awx/main/migrations/0032_v330_polymorphic_delete.py b/awx/main/migrations/0032_v330_polymorphic_delete.py new file mode 100644 index 0000000000..dd3d6a769f --- /dev/null +++ b/awx/main/migrations/0032_v330_polymorphic_delete.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-04-06 13:44 +from __future__ import unicode_literals + +import awx.main.utils.polymorphic +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0031_v330_encrypt_oauth2_secret'), + ] + + operations = [ + migrations.AlterField( + model_name='unifiedjob', + name='instance_group', + field=models.ForeignKey(blank=True, default=None, help_text='The Rampart/Instance group the job was run under', null=True, on_delete=awx.main.utils.polymorphic.SET_NULL, to='main.InstanceGroup'), + ), + ] diff --git a/awx/main/migrations/0033_v330_oauth_help_text.py b/awx/main/migrations/0033_v330_oauth_help_text.py new file mode 100644 index 0000000000..41704307b0 --- /dev/null +++ b/awx/main/migrations/0033_v330_oauth_help_text.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-04-11 15:54 +from __future__ import unicode_literals + +import awx.main.fields +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import oauth2_provider.generators + +# TODO: Squash all of these migrations with '0024_v330_add_oauth_activity_stream_registrar' + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0032_v330_polymorphic_delete'), + ] + + operations = [ + migrations.AlterField( + model_name='oauth2accesstoken', + name='scope', + field=models.TextField(blank=True, help_text="Allowed scopes, further restricts user's permissions."), + ), + migrations.AlterField( + model_name='oauth2accesstoken', + name='user', + field=models.ForeignKey(blank=True, help_text='The user representing the token owner', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='main_oauth2accesstoken', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='oauth2application', + name='authorization_grant_type', + field=models.CharField(choices=[(b'authorization-code', 'Authorization code'), (b'implicit', 'Implicit'), (b'password', 'Resource owner password-based'), (b'client-credentials', 'Client credentials')], help_text='The Grant type the user must use for acquire tokens for this application.', max_length=32), + ), + migrations.AlterField( + model_name='oauth2application', + name='client_secret', + field=awx.main.fields.OAuth2ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Used for more stringent verification of access to an application when creating a token.', max_length=1024), + ), + migrations.AlterField( + model_name='oauth2application', + name='client_type', + field=models.CharField(choices=[(b'confidential', 'Confidential'), (b'public', 'Public')], help_text='Set to Public or Confidential depending on how secure the client device is.', max_length=32), + ), + migrations.AlterField( + model_name='oauth2application', + name='skip_authorization', + field=models.BooleanField(default=False, help_text='Set True to skip authorization step for completely trusted applications.'), + ), + ] diff --git a/awx/main/migrations/0034_v330_delete_user_role.py b/awx/main/migrations/0034_v330_delete_user_role.py new file mode 100644 index 0000000000..6719e307ca --- /dev/null +++ b/awx/main/migrations/0034_v330_delete_user_role.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-04-02 19:18 +from __future__ import unicode_literals + +from django.db import migrations + +from awx.main.migrations import ActivityStreamDisabledMigration +from awx.main.migrations._rbac import delete_all_user_roles, rebuild_role_hierarchy +from awx.main.migrations import _migration_utils as migration_utils + + +class Migration(ActivityStreamDisabledMigration): + + dependencies = [ + ('main', '0033_v330_oauth_help_text'), + ] + + operations = [ + migrations.RunPython(migration_utils.set_current_apps_for_migrations), + migrations.RunPython(delete_all_user_roles), + migrations.RunPython(rebuild_role_hierarchy), + ] diff --git a/awx/main/migrations/0035_v330_more_oauth2_help_text.py b/awx/main/migrations/0035_v330_more_oauth2_help_text.py new file mode 100644 index 0000000000..95671c4f44 --- /dev/null +++ b/awx/main/migrations/0035_v330_more_oauth2_help_text.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-04-17 18:36 +from __future__ import unicode_literals + +from django.db import migrations, models + +# TODO: Squash all of these migrations with '0024_v330_add_oauth_activity_stream_registrar' + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0034_v330_delete_user_role'), + ] + + operations = [ + migrations.AlterField( + model_name='oauth2accesstoken', + name='scope', + field=models.TextField(blank=True, help_text="Allowed scopes, further restricts user's permissions. Must be a simple space-separated string with allowed scopes ['read', 'write']."), + ), + ] diff --git a/awx/main/migrations/0036_v330_credtype_remove_become_methods.py b/awx/main/migrations/0036_v330_credtype_remove_become_methods.py new file mode 100644 index 0000000000..3a43bd6a8b --- /dev/null +++ b/awx/main/migrations/0036_v330_credtype_remove_become_methods.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +# AWX +from awx.main.migrations import _credentialtypes as credentialtypes + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0035_v330_more_oauth2_help_text'), + + ] + + operations = [ + migrations.RunPython(credentialtypes.remove_become_methods), + ] diff --git a/awx/main/migrations/0037_v330_remove_legacy_fact_cleanup.py b/awx/main/migrations/0037_v330_remove_legacy_fact_cleanup.py new file mode 100644 index 0000000000..da2423bdcf --- /dev/null +++ b/awx/main/migrations/0037_v330_remove_legacy_fact_cleanup.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +# AWX +from awx.main.migrations._scan_jobs import remove_legacy_fact_cleanup + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0036_v330_credtype_remove_become_methods'), + ] + + operations = [ + migrations.RunPython(remove_legacy_fact_cleanup), + ] diff --git a/awx/main/migrations/_credentialtypes.py b/awx/main/migrations/_credentialtypes.py index bf4128c92e..fbf812e8c2 100644 --- a/awx/main/migrations/_credentialtypes.py +++ b/awx/main/migrations/_credentialtypes.py @@ -197,3 +197,9 @@ def add_azure_cloud_environment_field(apps, schema_editor): name='Microsoft Azure Resource Manager') azure_rm_credtype.inputs = CredentialType.defaults.get('azure_rm')().inputs azure_rm_credtype.save() + + +def remove_become_methods(apps, schema_editor): + become_credtype = CredentialType.objects.filter(kind='ssh', managed_by_tower=True).first() + become_credtype.inputs = CredentialType.defaults.get('ssh')().inputs + become_credtype.save() diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 80ecc69ebc..00233b085b 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -500,3 +500,12 @@ def infer_credential_org_from_team(apps, schema_editor): _update_credential_parents(cred.deprecated_team.organization, cred) except IntegrityError: logger.info("Organization<{}> credential for old Team<{}> credential already created".format(cred.deprecated_team.organization.pk, cred.pk)) + + +def delete_all_user_roles(apps, schema_editor): + ContentType = apps.get_model('contenttypes', "ContentType") + Role = apps.get_model('main', "Role") + User = apps.get_model('auth', "User") + user_content_type = ContentType.objects.get_for_model(User) + for role in Role.objects.filter(content_type=user_content_type).iterator(): + role.delete() diff --git a/awx/main/migrations/_scan_jobs.py b/awx/main/migrations/_scan_jobs.py index 0d91e3ef23..993b28c5d1 100644 --- a/awx/main/migrations/_scan_jobs.py +++ b/awx/main/migrations/_scan_jobs.py @@ -102,3 +102,11 @@ def remove_scan_type_nodes(apps, schema_editor): prompts.pop('job_type') node.char_prompts = prompts node.save() + + +def remove_legacy_fact_cleanup(apps, schema_editor): + SystemJobTemplate = apps.get_model('main', 'SystemJobTemplate') + for job in SystemJobTemplate.objects.filter(job_type='cleanup_facts').all(): + for sched in job.schedules.all(): + sched.delete() + job.delete() diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index d5f2fb7af0..0bbbc08254 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -56,7 +56,6 @@ User.add_to_class('get_queryset', get_user_queryset) User.add_to_class('can_access', check_user_access) User.add_to_class('can_access_with_errors', check_user_access_with_errors) User.add_to_class('accessible_objects', user_accessible_objects) -User.add_to_class('admin_role', user_admin_role) @property diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index d317208aa5..45d8cbea07 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -66,7 +66,6 @@ class ActivityStream(models.Model): label = models.ManyToManyField("Label", blank=True) role = models.ManyToManyField("Role", blank=True) instance_group = models.ManyToManyField("InstanceGroup", blank=True) - o_auth2_application = models.ManyToManyField("OAuth2Application", blank=True) o_auth2_access_token = models.ManyToManyField("OAuth2AccessToken", blank=True) diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 7639bc4548..fcca82474c 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -256,6 +256,7 @@ class PrimordialModel(CreatedModifiedModel): def save(self, *args, **kwargs): update_fields = kwargs.get('update_fields', []) + fields_are_specified = bool(update_fields) user = get_current_user() if user and not user.id: user = None @@ -263,9 +264,14 @@ class PrimordialModel(CreatedModifiedModel): self.created_by = user if 'created_by' not in update_fields: update_fields.append('created_by') - self.modified_by = user - if 'modified_by' not in update_fields: - update_fields.append('modified_by') + # Update modified_by if not called with update_fields, or if any + # editable fields are present in update_fields + if ( + (not fields_are_specified) or + any(getattr(self._meta.get_field(name), 'editable', True) for name in update_fields)): + self.modified_by = user + if 'modified_by' not in update_fields: + update_fields.append('modified_by') super(PrimordialModel, self).save(*args, **kwargs) def clean_description(self): diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 8908fcf8c0..48205dea1f 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -2,13 +2,12 @@ # All Rights Reserved. from collections import OrderedDict import functools -import json import logging -import operator import os import re import stat import tempfile +import six # Jinja2 from jinja2 import Template @@ -21,11 +20,11 @@ from django.utils.encoding import force_text # AWX from awx.api.versioning import reverse -from awx.main.constants import PRIVILEGE_ESCALATION_METHODS 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 @@ -34,6 +33,7 @@ from awx.main.models.rbac import ( ROLE_SINGLETON_SYSTEM_AUDITOR, ) from awx.main.utils import encrypt_field +from awx.main.constants import CHOICES_PRIVILEGE_ESCALATION_METHODS from . import injectors as builtin_injectors __all__ = ['Credential', 'CredentialType', 'V1Credential', 'build_safe_env'] @@ -164,7 +164,7 @@ class V1Credential(object): max_length=32, blank=True, default='', - choices=[('', _('None'))] + PRIVILEGE_ESCALATION_METHODS, + choices=CHOICES_PRIVILEGE_ESCALATION_METHODS, help_text=_('Privilege escalation method.') ), 'become_username': models.CharField( @@ -415,9 +415,9 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin): type_alias = self.credential_type_id if self.kind == 'vault' and self.inputs.get('vault_id', None): if display: - fmt_str = '{} (id={})' + fmt_str = six.text_type('{} (id={})') else: - fmt_str = '{}_{}' + fmt_str = six.text_type('{}_{}') return fmt_str.format(type_alias, self.inputs.get('vault_id')) return str(type_alias) @@ -445,6 +445,7 @@ class CredentialType(CommonModelNameNotUnique): 'AD_HOC_COMMAND_ID', 'REST_API_URL', 'REST_API_TOKEN', 'MAX_EVENT_RES', 'CALLBACK_QUEUE', 'CALLBACK_CONNECTION', 'CACHE', 'JOB_CALLBACK_DEBUG', 'INVENTORY_HOSTVARS', 'FACT_QUEUE', + 'AWX_HOST', 'PROJECT_REVISION' )) class Meta: @@ -514,7 +515,7 @@ class CredentialType(CommonModelNameNotUnique): if field['id'] == field_id: if 'choices' in field: return field['choices'][0] - return {'string': '', 'boolean': False}[field['type']] + return {'string': '', 'boolean': False, 'become_method': ''}[field['type']] @classmethod def default(cls, f): @@ -630,7 +631,7 @@ class CredentialType(CommonModelNameNotUnique): data = Template(file_tmpl).render(**namespace) _, path = tempfile.mkstemp(dir=private_data_dir) with open(path, 'w') as f: - f.write(data) + f.write(data.encode('utf-8')) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # determine if filename indicates single file or many @@ -651,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]) @@ -706,8 +702,7 @@ def ssh(cls): }, { 'id': 'become_method', 'label': 'Privilege Escalation Method', - 'choices': map(operator.itemgetter(0), - V1Credential.FIELDS['become_method'].choices), + 'type': 'become_method', 'help_text': ('Specify a method for "become" operations. This is ' 'equivalent to specifying the --become-method ' 'Ansible parameter.') diff --git a/awx/main/models/events.py b/awx/main/models/events.py index 09da2ffb20..21dcd90a24 100644 --- a/awx/main/models/events.py +++ b/awx/main/models/events.py @@ -2,7 +2,7 @@ import datetime import logging from django.conf import settings -from django.db import models +from django.db import models, DatabaseError from django.utils.dateparse import parse_datetime from django.utils.timezone import utc from django.utils.translation import ugettext_lazy as _ @@ -15,6 +15,8 @@ from awx.main.utils import ignore_inventory_computed_fields analytics_logger = logging.getLogger('awx.analytics.job_events') +logger = logging.getLogger('awx.main.models.events') + __all__ = ['JobEvent', 'ProjectUpdateEvent', 'AdHocCommandEvent', 'InventoryUpdateEvent', 'SystemJobEvent'] @@ -235,12 +237,6 @@ class BasePlaybookEvent(CreatedModifiedModel): if res.get('changed', False): self.changed = True updated_fields.add('changed') - # If we're not in verbose mode, wipe out any module arguments. - invocation = res.get('invocation', None) - if isinstance(invocation, dict) and self.job_verbosity == 0 and 'module_args' in invocation: - event_data['res']['invocation']['module_args'] = '' - self.event_data = event_data - updated_fields.add('event_data') if self.event == 'playbook_on_stats': try: failures_dict = event_data.get('failures', {}) @@ -329,7 +325,10 @@ class BasePlaybookEvent(CreatedModifiedModel): hostnames = self._hostnames() self._update_host_summary_from_stats(hostnames) - self.job.inventory.update_computed_fields() + try: + self.job.inventory.update_computed_fields() + except DatabaseError: + logger.exception('Computed fields database error saving event {}'.format(self.pk)) @@ -447,6 +446,9 @@ class JobEvent(BasePlaybookEvent): def _update_host_summary_from_stats(self, hostnames): with ignore_inventory_computed_fields(): + if not self.job or not self.job.inventory: + logger.info('Event {} missing job or inventory, host summaries not updated'.format(self.pk)) + return qs = self.job.inventory.hosts.filter(name__in=hostnames) job = self.job for host in hostnames: diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 3356dc3a5d..471276c560 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -85,6 +85,10 @@ class Instance(models.Model): # NOTE: TODO: Likely to repurpose this once standalone ramparts are a thing return "awx" + @property + def jobs_running(self): + return UnifiedJob.objects.filter(execution_node=self.hostname, status__in=('running', 'waiting',)).count() + def is_lost(self, ref_time=None, isolated=False): if ref_time is None: ref_time = now() @@ -188,9 +192,8 @@ class JobOrigin(models.Model): @receiver(post_save, sender=InstanceGroup) def on_instance_group_saved(sender, instance, created=False, raw=False, **kwargs): - if created: - from awx.main.tasks import apply_cluster_membership_policies - connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) + from awx.main.tasks import apply_cluster_membership_policies + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) @receiver(post_save, sender=Instance) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index b63e8a48c0..c748f841f3 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -233,7 +233,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): return {} else: all_group = data.setdefault('all', dict()) - smart_hosts_qs = self.hosts.all() + smart_hosts_qs = self.hosts.filter(**hosts_q).all() smart_hosts = list(smart_hosts_qs.values_list('name', flat=True)) all_group['hosts'] = smart_hosts else: @@ -517,7 +517,7 @@ class SmartInventoryMembership(BaseModel): host = models.ForeignKey('Host', related_name='+', on_delete=models.CASCADE) -class Host(CommonModelNameNotUnique): +class Host(CommonModelNameNotUnique, RelatedJobsMixin): ''' A managed node ''' @@ -703,6 +703,12 @@ class Host(CommonModelNameNotUnique): self._update_host_smart_inventory_memeberships() super(Host, self).delete(*args, **kwargs) + ''' + RelatedJobsMixin + ''' + def _get_related_jobs(self): + return self.inventory._get_related_jobs() + class Group(CommonModelNameNotUnique, RelatedJobsMixin): ''' diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index d60833edeb..51394aa830 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -538,7 +538,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana for virtualenv in ( self.job_template.custom_virtualenv if self.job_template else None, self.project.custom_virtualenv, - self.project.organization.custom_virtualenv + self.project.organization.custom_virtualenv if self.project.organization else None ): if virtualenv: return virtualenv diff --git a/awx/main/models/oauth.py b/awx/main/models/oauth.py index a1c13a23cd..45e13fc8b0 100644 --- a/awx/main/models/oauth.py +++ b/awx/main/models/oauth.py @@ -6,9 +6,13 @@ from django.core.validators import RegexValidator from django.db import models from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ +from django.conf import settings # Django OAuth Toolkit from oauth2_provider.models import AbstractApplication, AbstractAccessToken +from oauth2_provider.generators import generate_client_secret + +from awx.main.fields import OAuth2ClientSecretField DATA_URI_RE = re.compile(r'.*') # FIXME @@ -21,6 +25,24 @@ class OAuth2Application(AbstractApplication): class Meta: app_label = 'main' verbose_name = _('application') + + CLIENT_CONFIDENTIAL = "confidential" + CLIENT_PUBLIC = "public" + CLIENT_TYPES = ( + (CLIENT_CONFIDENTIAL, _("Confidential")), + (CLIENT_PUBLIC, _("Public")), + ) + + GRANT_AUTHORIZATION_CODE = "authorization-code" + GRANT_IMPLICIT = "implicit" + GRANT_PASSWORD = "password" + GRANT_CLIENT_CREDENTIALS = "client-credentials" + GRANT_TYPES = ( + (GRANT_AUTHORIZATION_CODE, _("Authorization code")), + (GRANT_IMPLICIT, _("Implicit")), + (GRANT_PASSWORD, _("Resource owner password-based")), + (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), + ) description = models.TextField( default='', @@ -31,6 +53,34 @@ class OAuth2Application(AbstractApplication): editable=False, validators=[RegexValidator(DATA_URI_RE)], ) + organization = models.ForeignKey( + 'Organization', + related_name='applications', + help_text=_('Organization containing this application.'), + on_delete=models.CASCADE, + null=True, + ) + client_secret = OAuth2ClientSecretField( + max_length=1024, + blank=True, + default=generate_client_secret, + db_index=True, + help_text=_('Used for more stringent verification of access to an application when creating a token.') + ) + client_type = models.CharField( + max_length=32, + choices=CLIENT_TYPES, + help_text=_('Set to Public or Confidential depending on how secure the client device is.') + ) + skip_authorization = models.BooleanField( + default=False, + help_text=_('Set True to skip authorization step for completely trusted applications.') + ) + authorization_grant_type = models.CharField( + max_length=32, + choices=GRANT_TYPES, + help_text=_('The Grant type the user must use for acquire tokens for this application.') + ) class OAuth2AccessToken(AbstractAccessToken): @@ -39,6 +89,14 @@ class OAuth2AccessToken(AbstractAccessToken): app_label = 'main' verbose_name = _('access token') + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", + help_text=_('The user representing the token owner') + ) description = models.CharField( max_length=200, default='', @@ -49,6 +107,10 @@ class OAuth2AccessToken(AbstractAccessToken): default=None, editable=False, ) + scope = models.TextField( + blank=True, + help_text=_('Allowed scopes, further restricts user\'s permissions. Must be a simple space-separated string with allowed scopes [\'read\', \'write\'].') + ) def is_valid(self, scopes=None): valid = super(OAuth2AccessToken, self).is_valid(scopes) diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index 8736647a65..71efa702c6 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -97,7 +97,7 @@ class Schedule(CommonModel, LaunchTimeConfig): @classmethod def rrulestr(cls, rrule, **kwargs): """ - Apply our own custom rrule parsing logic to support TZID= + Apply our own custom rrule parsing requirements """ kwargs['forceset'] = True x = dateutil.rrule.rrulestr(rrule, **kwargs) @@ -108,15 +108,6 @@ class Schedule(CommonModel, LaunchTimeConfig): 'A valid TZID must be provided (e.g., America/New_York)' ) - if r._dtstart and r._until: - # If https://github.com/dateutil/dateutil/pull/634 ever makes - # it into a python-dateutil release, we could remove this block. - if all(( - r._dtstart.tzinfo != dateutil.tz.tzlocal(), - r._until.tzinfo != dateutil.tz.tzutc(), - )): - raise ValueError('RRULE UNTIL values must be specified in UTC') - if 'MINUTELY' in rrule or 'HOURLY' in rrule: try: first_event = x[0] diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 08e5cecb1c..943956f7ac 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -38,6 +38,7 @@ from awx.main.utils import ( copy_model_by_class, copy_m2m_relationships, get_type_for_model, parse_yaml_or_json ) +from awx.main.utils import polymorphic from awx.main.constants import ACTIVE_STATES, CAN_CANCEL from awx.main.redact import UriCleaner, REPLACE_STR from awx.main.consumers import emit_channel_notification @@ -89,9 +90,6 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio ALL_STATUS_CHOICES = OrderedDict(PROJECT_STATUS_CHOICES + INVENTORY_SOURCE_STATUS_CHOICES + JOB_TEMPLATE_STATUS_CHOICES + DEPRECATED_STATUS_CHOICES).items() - # NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229 - base_manager_name = 'base_objects' - class Meta: app_label = 'main' # unique_together here is intentionally commented out. Please make sure sub-classes of this model @@ -265,14 +263,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio if field not in update_fields: update_fields.append(field) # Do the actual save. - try: - super(UnifiedJobTemplate, self).save(*args, **kwargs) - except ValueError: - # A fix for https://trello.com/c/S4rU1F21 - # Does not resolve the root cause. Tis merely a bandaid. - if 'scm_delete_on_next_update' in update_fields: - update_fields.remove('scm_delete_on_next_update') - super(UnifiedJobTemplate, self).save(*args, **kwargs) + super(UnifiedJobTemplate, self).save(*args, **kwargs) def _get_current_status(self): @@ -536,9 +527,6 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique PASSWORD_FIELDS = ('start_args',) - # NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229 - base_manager_name = 'base_objects' - class Meta: app_label = 'main' @@ -669,7 +657,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique blank=True, null=True, default=None, - on_delete=models.SET_NULL, + on_delete=polymorphic.SET_NULL, help_text=_('The Rampart/Instance group the job was run under'), ) credentials = models.ManyToManyField( @@ -727,7 +715,10 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique def _get_parent_instance(self): return getattr(self, self._get_parent_field_name(), None) - def _update_parent_instance_no_save(self, parent_instance, update_fields=[]): + def _update_parent_instance_no_save(self, parent_instance, update_fields=None): + if update_fields is None: + update_fields = [] + def parent_instance_set(key, val): setattr(parent_instance, key, val) if key not in update_fields: @@ -1268,10 +1259,6 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique if not all(opts.values()): return False - # Sanity check: If we are running unit tests, then run synchronously. - if getattr(settings, 'CELERY_UNIT_TEST', False): - return self.start(None, None, **kwargs) - # Save the pending status, and inform the SocketIO listener. self.update_fields(start_args=json.dumps(kwargs), status='pending') self.websocket_emit_status("pending") diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 009efd0aab..c63bbc6f1f 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -389,10 +389,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl return prompted_fields, rejected_fields, errors_dict def can_start_without_user_input(self): - return not bool( - self.variables_needed_to_start or - self.node_templates_missing() or - self.node_prompts_rejected()) + return not bool(self.variables_needed_to_start) def node_templates_missing(self): return [node.pk for node in self.workflow_job_template_nodes.filter( @@ -477,7 +474,7 @@ class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificatio @property def preferred_instance_groups(self): - return self.global_instance_groups + return [] ''' A WorkflowJob is a virtual job. It doesn't result in a celery task. diff --git a/awx/main/redact.py b/awx/main/redact.py index 9d3b6a595d..16c8fc0513 100644 --- a/awx/main/redact.py +++ b/awx/main/redact.py @@ -6,8 +6,7 @@ REPLACE_STR = '$encrypted$' class UriCleaner(object): REPLACE_STR = REPLACE_STR - # https://regex101.com/r/sV2dO2/2 - SENSITIVE_URI_PATTERN = re.compile(ur'(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:\'".,<>?\xab\xbb\u201c\u201d\u2018\u2019]))', re.MULTILINE) # NOQA + SENSITIVE_URI_PATTERN = re.compile(ur'(\w+:(\/?\/?)[^\s]+)', re.MULTILINE) # NOQA @staticmethod def remove_sensitive(cleartext): @@ -17,38 +16,46 @@ class UriCleaner(object): match = UriCleaner.SENSITIVE_URI_PATTERN.search(redactedtext, text_index) if not match: break - o = urlparse.urlsplit(match.group(1)) - if not o.username and not o.password: - if o.netloc and ":" in o.netloc: - # Handle the special case url http://username:password that can appear in SCM url - # on account of a bug? in ansible redaction - (username, password) = o.netloc.split(':') + try: + uri_str = match.group(1) + # May raise a ValueError if invalid URI for one reason or another + o = urlparse.urlsplit(uri_str) + + if not o.username and not o.password: + if o.netloc and ":" in o.netloc: + # Handle the special case url http://username:password that can appear in SCM url + # on account of a bug? in ansible redaction + (username, password) = o.netloc.split(':') + else: + text_index += len(match.group(1)) + continue else: - text_index += len(match.group(1)) - continue - else: - username = o.username - password = o.password + username = o.username + password = o.password - # Given a python MatchObject, with respect to redactedtext, find and - # replace the first occurance of username and the first and second - # occurance of password + # Given a python MatchObject, with respect to redactedtext, find and + # replace the first occurance of username and the first and second + # occurance of password - uri_str = redactedtext[match.start():match.end()] - if username: - uri_str = uri_str.replace(username, UriCleaner.REPLACE_STR, 1) - # 2, just in case the password is $encrypted$ - if password: - uri_str = uri_str.replace(password, UriCleaner.REPLACE_STR, 2) + uri_str = redactedtext[match.start():match.end()] + if username: + uri_str = uri_str.replace(username, UriCleaner.REPLACE_STR, 1) + # 2, just in case the password is $encrypted$ + if password: + uri_str = uri_str.replace(password, UriCleaner.REPLACE_STR, 2) - t = redactedtext[:match.start()] + uri_str - text_index = len(t) - if (match.end() < len(redactedtext)): - t += redactedtext[match.end():] + t = redactedtext[:match.start()] + uri_str + text_index = len(t) + if (match.end() < len(redactedtext)): + t += redactedtext[match.end():] - redactedtext = t - if text_index >= len(redactedtext): - text_index = len(redactedtext) - 1 + redactedtext = t + if text_index >= len(redactedtext): + text_index = len(redactedtext) - 1 + except ValueError: + # Invalid URI, redact the whole URI to be safe + redactedtext = redactedtext[:match.start()] + UriCleaner.REPLACE_STR + redactedtext[match.end():] + text_index = match.start() + len(UriCleaner.REPLACE_STR) return redactedtext diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index 254b9472d3..943d4960e6 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -153,8 +153,7 @@ class TaskManager(): queue_name = queue_name[1 if len(queue_name) > 1 else 0] queues[queue_name] = active_tasks else: - if not hasattr(settings, 'CELERY_UNIT_TEST'): - return (None, None) + return (None, None) return (active_task_queues, queues) @@ -260,7 +259,8 @@ class TaskManager(): else: if type(task) is WorkflowJob: task.status = 'running' - if not task.supports_isolation() and rampart_group.controller_id: + logger.info('Transitioning %s to running status.', task.log_format) + elif not task.supports_isolation() and rampart_group.controller_id: # non-Ansible jobs on isolated instances run on controller task.instance_group = rampart_group.controller logger.info('Submitting isolated %s to queue %s via %s.', @@ -272,17 +272,22 @@ class TaskManager(): task.celery_task_id = str(uuid.uuid4()) task.save() - self.consume_capacity(task, rampart_group.name) + if rampart_group is not None: + self.consume_capacity(task, rampart_group.name) def post_commit(): task.websocket_emit_status(task.status) if task.status != 'failed': - task.start_celery_task(opts, error_callback=error_handler, success_callback=success_handler, queue=rampart_group.name) + if rampart_group is not None: + actual_queue=rampart_group.name + else: + actual_queue=settings.CELERY_DEFAULT_QUEUE + task.start_celery_task(opts, error_callback=error_handler, success_callback=success_handler, queue=actual_queue) connection.on_commit(post_commit) def process_running_tasks(self, running_tasks): - map(lambda task: self.graph[task.instance_group.name]['graph'].add_job(task), running_tasks) + map(lambda task: self.graph[task.instance_group.name]['graph'].add_job(task) if task.instance_group else None, running_tasks) def create_project_update(self, task): project_task = Project.objects.get(id=task.project_id).create_project_update( @@ -448,6 +453,9 @@ class TaskManager(): continue preferred_instance_groups = task.preferred_instance_groups found_acceptable_queue = False + if isinstance(task, WorkflowJob): + self.start_task(task, None, task.get_jobs_fail_chain()) + continue for rampart_group in preferred_instance_groups: remaining_capacity = self.get_remaining_capacity(rampart_group.name) if remaining_capacity <= 0: diff --git a/awx/main/scheduler/tasks.py b/awx/main/scheduler/tasks.py index 89e36f6a93..194b188146 100644 --- a/awx/main/scheduler/tasks.py +++ b/awx/main/scheduler/tasks.py @@ -3,7 +3,7 @@ import logging # Celery -from celery import Task, shared_task +from celery import shared_task # AWX from awx.main.scheduler import TaskManager @@ -15,23 +15,17 @@ logger = logging.getLogger('awx.main.scheduler') # updated model, the call to schedule() may get stale data. -class LogErrorsTask(Task): - def on_failure(self, exc, task_id, args, kwargs, einfo): - logger.exception('Task {} encountered exception.'.format(self.name), exc_info=exc) - super(LogErrorsTask, self).on_failure(exc, task_id, args, kwargs, einfo) - - -@shared_task(base=LogErrorsTask) +@shared_task() def run_job_launch(job_id): TaskManager().schedule() -@shared_task(base=LogErrorsTask) +@shared_task() def run_job_complete(job_id): TaskManager().schedule() -@shared_task(base=LogErrorsTask) +@shared_task() def run_task_manager(): logger.debug("Running Tower task manager.") TaskManager().schedule() diff --git a/awx/main/signals.py b/awx/main/signals.py index 52bd5e55c0..a702fd2aaa 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -9,7 +9,13 @@ import json # Django from django.conf import settings -from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed +from django.db.models.signals import ( + post_init, + post_save, + pre_delete, + post_delete, + m2m_changed, +) from django.dispatch import receiver from django.contrib.auth import SESSION_KEY from django.utils import timezone @@ -25,10 +31,14 @@ import six from awx.main.models import * # noqa from django.contrib.sessions.models import Session from awx.api.serializers import * # noqa +from awx.main.constants import TOKEN_CENSOR from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates from awx.main.tasks import update_inventory_computed_fields -from awx.main.fields import is_implicit_parent +from awx.main.fields import ( + is_implicit_parent, + update_role_parentage_for_instance, +) from awx.main import consumers @@ -162,39 +172,6 @@ def sync_superuser_status_to_rbac(instance, **kwargs): Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).members.remove(instance) -def create_user_role(instance, **kwargs): - if not kwargs.get('created', True): - return - try: - Role.objects.get( - content_type=ContentType.objects.get_for_model(instance), - object_id=instance.id, - role_field='admin_role' - ) - except Role.DoesNotExist: - role = Role.objects.create( - role_field='admin_role', - content_object = instance, - ) - role.members.add(instance) - - -def org_admin_edit_members(instance, action, model, reverse, pk_set, **kwargs): - content_type = ContentType.objects.get_for_model(Organization) - - if reverse: - return - else: - if instance.content_type == content_type and \ - instance.content_object.member_role.id == instance.id: - items = model.objects.filter(pk__in=pk_set).all() - for user in items: - if action == 'post_add': - instance.content_object.admin_role.children.add(user.admin_role) - if action == 'pre_remove': - instance.content_object.admin_role.children.remove(user.admin_role) - - def rbac_activity_stream(instance, sender, **kwargs): user_type = ContentType.objects.get_for_model(User) # Only if we are associating/disassociating @@ -223,6 +200,29 @@ def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs): l.delete() +def set_original_organization(sender, instance, **kwargs): + '''set_original_organization is used to set the original, or + pre-save organization, so we can later determine if the organization + field is dirty. + ''' + instance.__original_org = instance.organization + + +def save_related_job_templates(sender, instance, **kwargs): + '''save_related_job_templates loops through all of the + job templates that use an Inventory or Project that have had their + Organization updated. This triggers the rebuilding of the RBAC hierarchy + and ensures the proper access restrictions. + ''' + if sender not in (Project, Inventory): + raise ValueError('This signal callback is only intended for use with Project or Inventory') + + if instance.__original_org != instance.organization: + jtq = JobTemplate.objects.filter(**{sender.__name__.lower(): instance}) + for jt in jtq: + update_role_parentage_for_instance(jt) + + def connect_computed_field_signals(): post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -240,18 +240,19 @@ def connect_computed_field_signals(): connect_computed_field_signals() - +post_init.connect(set_original_organization, sender=Project) +post_init.connect(set_original_organization, sender=Inventory) +post_save.connect(save_related_job_templates, sender=Project) +post_save.connect(save_related_job_templates, sender=Inventory) post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) post_save.connect(emit_project_update_event_detail, sender=ProjectUpdateEvent) post_save.connect(emit_inventory_update_event_detail, sender=InventoryUpdateEvent) post_save.connect(emit_system_job_event_detail, sender=SystemJobEvent) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) -m2m_changed.connect(org_admin_edit_members, Role.members.through) m2m_changed.connect(rbac_activity_stream, Role.members.through) m2m_changed.connect(rbac_activity_stream, Role.parents.through) post_save.connect(sync_superuser_status_to_rbac, sender=User) -post_save.connect(create_user_role, sender=User) pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJob) pre_delete.connect(cleanup_detached_labels_on_deleted_parent, sender=UnifiedJobTemplate) @@ -400,6 +401,14 @@ model_serializer_mapping = { AdHocCommand: AdHocCommandSerializer, NotificationTemplate: NotificationTemplateSerializer, Notification: NotificationSerializer, + CredentialType: CredentialTypeSerializer, + Schedule: ScheduleSerializer, + Label: LabelSerializer, + WorkflowJobTemplate: WorkflowJobTemplateSerializer, + WorkflowJobTemplateNode: WorkflowJobTemplateNodeSerializer, + WorkflowJob: WorkflowJobSerializer, + OAuth2AccessToken: OAuth2TokenSerializer, + OAuth2Application: OAuth2ApplicationSerializer, } @@ -419,7 +428,7 @@ def activity_stream_create(sender, instance, created, **kwargs): if 'extra_vars' in changes: changes['extra_vars'] = instance.display_extra_vars() if type(instance) == OAuth2AccessToken: - changes['token'] = '*************' + changes['token'] = TOKEN_CENSOR activity_entry = ActivityStream( operation='create', object1=object1, @@ -620,12 +629,3 @@ def create_access_token_user_if_missing(sender, **kwargs): post_save.connect(create_access_token_user_if_missing, sender=OAuth2AccessToken) -# @receiver(post_save, sender=User) -# def create_default_oauth_app(sender, **kwargs): -# if kwargs.get('created', False): -# user = kwargs['instance'] -# OAuth2Application.objects.create( -# name='Default application for {}'.format(user.username), -# user=user, client_type='confidential', redirect_uris='', -# authorization_grant_type='password' -# ) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index c47101b988..31edcc38d7 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -13,6 +13,7 @@ import os import re import shutil import stat +import sys import tempfile import time import traceback @@ -55,8 +56,9 @@ from awx.main.queue import CallbackQueueDispatcher from awx.main.expect import run, isolated_manager from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url, check_proot_installed, build_proot_temp_dir, get_licenser, - wrap_args_with_proot, OutputEventFilter, ignore_inventory_computed_fields, + 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 @@ -80,8 +82,8 @@ Try upgrading OpenSSH or providing your private key in an different format. \ logger = logging.getLogger('awx.main.tasks') -class LogErrorsTask(Task): - def on_failure(self, exc, task_id, args, kwargs, einfo): +def log_celery_failure(self, exc, task_id, args, kwargs, einfo): + try: if getattr(exc, 'is_awx_task_error', False): # Error caused by user / tracked in job output logger.warning(six.text_type("{}").format(exc)) @@ -91,7 +93,12 @@ class LogErrorsTask(Task): .format(get_type_for_model(self.model), args[0])) else: logger.exception(six.text_type('Task {} encountered exception.').format(self.name), exc_info=exc) - super(LogErrorsTask, self).on_failure(exc, task_id, args, kwargs, einfo) + except Exception: + # It's fairly critical that this code _not_ raise exceptions on logging + # If you configure external logging in a way that _it_ fails, there's + # not a lot we can do here; sys.stderr.write is a final hail mary + _, _, tb = sys.exc_info() + traceback.print_tb(tb) @celeryd_init.connect @@ -116,7 +123,6 @@ def task_set_logger_pre_run(*args, **kwargs): cache.close() configure_external_logger(settings, is_startup=False) except Exception: - # General exception because LogErrorsTask not used with celery signals logger.exception('Encountered error on initial log configuration.') @@ -129,11 +135,10 @@ def inform_cluster_of_shutdown(*args, **kwargs): logger.warning(six.text_type('Normal shutdown signal for instance {}, ' 'removed self from capacity pool.').format(this_inst.hostname)) except Exception: - # General exception because LogErrorsTask not used with celery signals logger.exception('Encountered problem with normal shutdown signal.') -@shared_task(bind=True, queue='tower_instance_router', base=LogErrorsTask) +@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE) def apply_cluster_membership_policies(self): with advisory_lock('cluster_policy_lock', wait=True): considered_instances = Instance.objects.all().order_by('id') @@ -143,6 +148,7 @@ def apply_cluster_membership_policies(self): actual_instances = [] Group = namedtuple('Group', ['obj', 'instances']) Node = namedtuple('Instance', ['obj', 'groups']) + # Process policy instance list first, these will represent manually managed instances # that will not go through automatic policy determination for ig in InstanceGroup.objects.all(): @@ -183,7 +189,7 @@ def apply_cluster_membership_policies(self): handle_ha_toplogy_changes.apply([]) -@shared_task(queue='tower_broadcast_all', bind=True, base=LogErrorsTask) +@shared_task(queue='tower_broadcast_all', bind=True) def handle_setting_changes(self, setting_keys): orig_len = len(setting_keys) for i in range(orig_len): @@ -202,9 +208,11 @@ def handle_setting_changes(self, setting_keys): restart_local_services(['uwsgi']) -@shared_task(bind=True, queue='tower_broadcast_all', base=LogErrorsTask) +@shared_task(bind=True, queue='tower_broadcast_all') def handle_ha_toplogy_changes(self): - instance = Instance.objects.me() + (changed, instance) = Instance.objects.get_or_register() + if changed: + logger.info(six.text_type("Registered tower node '{}'").format(instance.hostname)) logger.debug(six.text_type("Reconfigure celeryd queues task on host {}").format(self.request.hostname)) awx_app = Celery('awx') awx_app.config_from_object('django.conf:settings') @@ -234,7 +242,9 @@ def handle_ha_toplogy_worker_ready(sender, **kwargs): def handle_update_celery_routes(sender=None, conf=None, **kwargs): conf = conf if conf else sender.app.conf logger.debug(six.text_type("Registering celery routes for {}").format(sender)) - instance = Instance.objects.me() + (changed, instance) = Instance.objects.get_or_register() + if changed: + logger.info(six.text_type("Registered tower node '{}'").format(instance.hostname)) added_routes = update_celery_worker_routes(instance, conf) logger.info(six.text_type("Workers on tower node '{}' added routes {} all routes are now {}") .format(instance.hostname, added_routes, conf.CELERY_ROUTES)) @@ -242,12 +252,14 @@ def handle_update_celery_routes(sender=None, conf=None, **kwargs): @celeryd_after_setup.connect def handle_update_celery_hostname(sender, instance, **kwargs): - tower_instance = Instance.objects.me() + (changed, tower_instance) = Instance.objects.get_or_register() + if changed: + logger.info(six.text_type("Registered tower node '{}'").format(tower_instance.hostname)) instance.hostname = 'celery@{}'.format(tower_instance.hostname) logger.warn(six.text_type("Set hostname to {}").format(instance.hostname)) -@shared_task(queue='tower', base=LogErrorsTask) +@shared_task(queue=settings.CELERY_DEFAULT_QUEUE) def send_notifications(notification_list, job_id=None): if not isinstance(notification_list, list): raise TypeError("notification_list should be of type list") @@ -259,6 +271,7 @@ def send_notifications(notification_list, job_id=None): job_actual.notifications.add(*notifications) for notification in notifications: + update_fields = ['status', 'notifications_sent'] try: sent = notification.notification_template.send(notification.subject, notification.body) notification.status = "successful" @@ -267,11 +280,12 @@ def send_notifications(notification_list, job_id=None): logger.error(six.text_type("Send Notification Failed {}").format(e)) notification.status = "failed" notification.error = smart_str(e) + update_fields.append('error') finally: - notification.save() + notification.save(update_fields=update_fields) -@shared_task(bind=True, queue='tower', base=LogErrorsTask) +@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE) def run_administrative_checks(self): logger.warn("Running administrative checks.") if not settings.TOWER_ADMIN_ALERTS: @@ -293,7 +307,7 @@ def run_administrative_checks(self): fail_silently=True) -@shared_task(bind=True, base=LogErrorsTask) +@shared_task(bind=True) def purge_old_stdout_files(self): nowtime = time.time() for f in os.listdir(settings.JOBOUTPUT_ROOT): @@ -302,14 +316,18 @@ def purge_old_stdout_files(self): logger.info(six.text_type("Removing {}").format(os.path.join(settings.JOBOUTPUT_ROOT,f))) -@shared_task(bind=True, base=LogErrorsTask) +@shared_task(bind=True) def cluster_node_heartbeat(self): logger.debug("Cluster node heartbeat task.") nowtime = now() - instance_list = list(Instance.objects.filter(rampart_groups__controller__isnull=True).distinct()) + instance_list = list(Instance.objects.all_non_isolated()) this_inst = None lost_instances = [] + (changed, instance) = Instance.objects.get_or_register() + if changed: + logger.info(six.text_type("Registered tower node '{}'").format(instance.hostname)) + for inst in list(instance_list): if inst.hostname == settings.CLUSTER_HOST_ID: this_inst = inst @@ -371,7 +389,7 @@ def cluster_node_heartbeat(self): logger.exception(six.text_type('Error marking {} as lost').format(other_inst.hostname)) -@shared_task(bind=True, base=LogErrorsTask) +@shared_task(bind=True) def awx_isolated_heartbeat(self): local_hostname = settings.CLUSTER_HOST_ID logger.debug("Controlling node checking for any isolated management tasks.") @@ -395,7 +413,7 @@ def awx_isolated_heartbeat(self): isolated_manager.IsolatedManager.health_check(isolated_instance_qs, awx_application_version) -@shared_task(bind=True, queue='tower', base=LogErrorsTask) +@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE) def awx_periodic_scheduler(self): run_now = now() state = TowerScheduleState.get_solo() @@ -430,7 +448,7 @@ def awx_periodic_scheduler(self): state.save() -@shared_task(bind=True, queue='tower', base=LogErrorsTask) +@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE) def handle_work_success(self, result, task_actual): try: instance = UnifiedJob.get_instance_by_type(task_actual['type'], task_actual['id']) @@ -444,7 +462,7 @@ def handle_work_success(self, result, task_actual): run_job_complete.delay(instance.id) -@shared_task(queue='tower', base=LogErrorsTask) +@shared_task(queue=settings.CELERY_DEFAULT_QUEUE) def handle_work_error(task_id, *args, **kwargs): subtasks = kwargs.get('subtasks', None) logger.debug('Executing error task id %s, subtasks: %s' % (task_id, str(subtasks))) @@ -485,7 +503,7 @@ def handle_work_error(task_id, *args, **kwargs): pass -@shared_task(queue='tower', base=LogErrorsTask) +@shared_task(queue=settings.CELERY_DEFAULT_QUEUE) def update_inventory_computed_fields(inventory_id, should_update_hosts=True): ''' Signal handler and wrapper around inventory.update_computed_fields to @@ -505,7 +523,7 @@ def update_inventory_computed_fields(inventory_id, should_update_hosts=True): raise -@shared_task(queue='tower', base=LogErrorsTask) +@shared_task(queue=settings.CELERY_DEFAULT_QUEUE) def update_host_smart_inventory_memberships(): try: with transaction.atomic(): @@ -530,7 +548,7 @@ def update_host_smart_inventory_memberships(): smart_inventory.update_computed_fields(update_groups=False, update_hosts=False) -@shared_task(bind=True, queue='tower', base=LogErrorsTask, max_retries=5) +@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE, max_retries=5) def delete_inventory(self, inventory_id, user_id): # Delete inventory as user if user_id is None: @@ -575,7 +593,7 @@ def with_path_cleanup(f): return _wrapped -class BaseTask(LogErrorsTask): +class BaseTask(Task): name = None model = None event_model = None @@ -710,7 +728,10 @@ class BaseTask(LogErrorsTask): 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 @@ -724,7 +745,6 @@ class BaseTask(LogErrorsTask): 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") + ":" @@ -811,19 +831,26 @@ class BaseTask(LogErrorsTask): def get_stdout_handle(self, instance): ''' - Return an virtual file object for capturing stdout and events. + Return an virtual file object for capturing stdout and/or events. ''' dispatcher = CallbackQueueDispatcher() - def event_callback(event_data): - event_data.setdefault(self.event_data_key, instance.id) - if 'uuid' in event_data: - cache_event = cache.get('ev-{}'.format(event_data['uuid']), None) - if cache_event is not None: - event_data.update(cache_event) - dispatcher.dispatch(event_data) + if isinstance(instance, (Job, AdHocCommand, ProjectUpdate)): + def event_callback(event_data): + event_data.setdefault(self.event_data_key, instance.id) + if 'uuid' in event_data: + cache_event = cache.get('ev-{}'.format(event_data['uuid']), None) + if cache_event is not None: + event_data.update(cache_event) + dispatcher.dispatch(event_data) - return OutputEventFilter(event_callback) + return OutputEventFilter(event_callback) + else: + def event_callback(event_data): + event_data.setdefault(self.event_data_key, instance.id) + dispatcher.dispatch(event_data) + + return OutputVerboseFilter(event_callback) def pre_run_hook(self, instance, **kwargs): ''' @@ -856,6 +883,7 @@ class BaseTask(LogErrorsTask): output_replacements = [] extra_update_fields = {} event_ct = 0 + stdout_handle = None try: kwargs['isolated'] = isolated_host is not None self.pre_run_hook(instance, **kwargs) @@ -966,15 +994,16 @@ class BaseTask(LogErrorsTask): ) except Exception: - if status != 'canceled': - tb = traceback.format_exc() - if settings.DEBUG: - logger.exception('%s Exception occurred while running task', instance.log_format) + # run_pexpect does not throw exceptions for cancel or timeout + # this could catch programming or file system errors + tb = traceback.format_exc() + logger.exception('%s Exception occurred while running task', instance.log_format) finally: try: - stdout_handle.flush() - stdout_handle.close() - event_ct = getattr(stdout_handle, '_event_ct', 0) + if stdout_handle: + stdout_handle.flush() + stdout_handle.close() + event_ct = getattr(stdout_handle, '_event_ct', 0) logger.info('%s finished running, producing %s events.', instance.log_format, event_ct) except Exception: @@ -997,7 +1026,7 @@ class BaseTask(LogErrorsTask): except Exception: logger.exception(six.text_type('{} Final run hook errored.').format(instance.log_format)) instance.websocket_emit_status(status) - if status != 'successful' and not hasattr(settings, 'CELERY_UNIT_TEST'): + if status != 'successful': # Raising an exception will mark the job as 'failed' in celery # and will stop a task chain from continuing to execute if status == 'canceled': @@ -1135,7 +1164,6 @@ class RunJob(BaseTask): if not kwargs.get('isolated'): env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_path env['ANSIBLE_STDOUT_CALLBACK'] = 'awx_display' - env['TOWER_HOST'] = settings.TOWER_URL_BASE env['AWX_HOST'] = settings.TOWER_URL_BASE env['CACHE'] = settings.CACHES['default']['LOCATION'] if 'LOCATION' in settings.CACHES['default'] else '' @@ -1195,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: @@ -1203,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') @@ -1242,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). @@ -2172,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 @@ -2180,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') @@ -2206,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) @@ -2255,19 +2296,14 @@ class RunSystemJob(BaseTask): json_vars = {} else: json_vars = json.loads(system_job.extra_vars) - if 'days' in json_vars and system_job.job_type != 'cleanup_facts': + if 'days' in json_vars: args.extend(['--days', str(json_vars.get('days', 60))]) - if 'dry_run' in json_vars and json_vars['dry_run'] and system_job.job_type != 'cleanup_facts': + if 'dry_run' in json_vars and json_vars['dry_run']: args.extend(['--dry-run']) if system_job.job_type == 'cleanup_jobs': args.extend(['--jobs', '--project-updates', '--inventory-updates', '--management-jobs', '--ad-hoc-commands', '--workflow-jobs', '--notifications']) - if system_job.job_type == 'cleanup_facts': - if 'older_than' in json_vars: - args.extend(['--older_than', str(json_vars['older_than'])]) - if 'granularity' in json_vars: - args.extend(['--granularity', str(json_vars['granularity'])]) except Exception: logger.exception(six.text_type("{} Failed to parse system job").format(system_job.log_format)) return args @@ -2299,7 +2335,7 @@ def _reconstruct_relationships(copy_mapping): new_obj.save() -@shared_task(bind=True, queue='tower', base=LogErrorsTask) +@shared_task(bind=True, queue=settings.CELERY_DEFAULT_QUEUE) def deep_copy_model_obj( self, model_module, model_name, obj_pk, new_obj_pk, user_pk, sub_obj_list, permission_check_func=None diff --git a/awx/main/tests/base.py b/awx/main/tests/base.py deleted file mode 100644 index 8535d0c4ea..0000000000 --- a/awx/main/tests/base.py +++ /dev/null @@ -1,687 +0,0 @@ -# Copyright (c) 2015 Ansible, Inc. -# All Rights Reserved. - -# Python -import base64 -import contextlib -import json -import os -import random -import shutil -import sys -import tempfile -import time -import urllib -import re -import mock - -# PyYAML -import yaml - -# Django -import django.test -from django.conf import settings, UserSettingsHolder -from django.contrib.auth.models import User -from django.core.cache import cache -from django.test.client import Client -from django.test.utils import override_settings -from django.utils.encoding import force_text - -# AWX -from awx.main.models import * # noqa -from awx.main.utils import get_ansible_version -from awx.sso.backends import LDAPSettings -from awx.main.tests.URI import URI # noqa - -TEST_PLAYBOOK = '''- hosts: mygroup - gather_facts: false - tasks: - - name: woohoo - command: test 1 = 1 -''' - - -class QueueTestMixin(object): - def start_queue(self): - self.start_rabbit() - receiver = CallbackReceiver() - self.queue_process = Process(target=receiver.run_subscriber, - args=(False,)) - self.queue_process.start() - - def terminate_queue(self): - if hasattr(self, 'queue_process'): - self.queue_process.terminate() - self.stop_rabbit() - - def start_rabbit(self): - if not getattr(self, 'redis_process', None): - # Centos 6.5 redis is runnable by non-root user but is not in a normal users path by default - env = dict(os.environ) - env['PATH'] = '%s:/usr/sbin/' % env['PATH'] - env['RABBITMQ_NODENAME'] = 'towerunittest' - env['RABBITMQ_NODE_PORT'] = '55672' - self.redis_process = Popen('rabbitmq-server > /dev/null', - shell=True, executable='/bin/bash', - env=env) - - def stop_rabbit(self): - if getattr(self, 'redis_process', None): - self.redis_process.kill() - self.redis_process = None - - -# The observed effect of not calling terminate_queue() if you call start_queue() are -# an hang on test cleanup database delete. Thus, to ensure terminate_queue() is called -# whenever start_queue() is called just inherit from this class when you want to use the queue. -class QueueStartStopTestMixin(QueueTestMixin): - def setUp(self): - super(QueueStartStopTestMixin, self).setUp() - self.start_queue() - - def tearDown(self): - super(QueueStartStopTestMixin, self).tearDown() - self.terminate_queue() - - -class MockCommonlySlowTestMixin(object): - def __init__(self, *args, **kwargs): - from awx.api import generics - mock.patch.object(generics, 'get_view_description', return_value=None).start() - super(MockCommonlySlowTestMixin, self).__init__(*args, **kwargs) - - -ansible_version = get_ansible_version() - - -class BaseTestMixin(MockCommonlySlowTestMixin): - ''' - Mixin with shared code for use by all test cases. - ''' - - def setUp(self): - super(BaseTestMixin, self).setUp() - global ansible_version - - self.object_ctr = 0 - # Save sys.path before tests. - self._sys_path = [x for x in sys.path] - # Save os.environ before tests. - self._environ = dict(os.environ.items()) - # Capture current directory to change back after each test. - self._cwd = os.getcwd() - # Capture list of temp files/directories created during tests. - self._temp_paths = [] - self._current_auth = None - self._user_passwords = {} - self.ansible_version = ansible_version - self.assertNotEqual(self.ansible_version, 'unknown') - # Wrap settings so we can redefine them within each test. - self._wrapped = settings._wrapped - settings._wrapped = UserSettingsHolder(settings._wrapped) - # Set all AUTH_LDAP_* settings to defaults to avoid using LDAP for - # tests unless expicitly configured. - for name, value in LDAPSettings.defaults.items(): - if name == 'SERVER_URI': - value = '' - setattr(settings, 'AUTH_LDAP_%s' % name, value) - # Pass test database settings in environment for use by any management - # commands that run from tests. - for opt in ('ENGINE', 'NAME', 'USER', 'PASSWORD', 'HOST', 'PORT'): - os.environ['AWX_TEST_DATABASE_%s' % opt] = settings.DATABASES['default'][opt] - # Set flag so that task chain works with unit tests. - settings.CELERY_UNIT_TEST = True - settings.SYSTEM_UUID='00000000-0000-0000-0000-000000000000' - settings.CELERY_BROKER_URL='redis://localhost:55672/' - settings.CALLBACK_QUEUE = 'callback_tasks_unit' - - # Disable socket notifications for unit tests. - settings.SOCKETIO_NOTIFICATION_PORT = None - # Make temp job status directory for unit tests. - job_status_dir = tempfile.mkdtemp() - self._temp_paths.append(job_status_dir) - settings.JOBOUTPUT_ROOT = os.path.abspath(job_status_dir) - settings.CACHES = { - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'unittests' - } - } - cache.clear() - self._start_time = time.time() - - def tearDown(self): - super(BaseTestMixin, self).tearDown() - # Restore sys.path after tests. - sys.path = self._sys_path - # Restore os.environ after tests. - for k,v in self._environ.items(): - if os.environ.get(k, None) != v: - os.environ[k] = v - for k,v in os.environ.items(): - if k not in self._environ.keys(): - del os.environ[k] - # Restore current directory after each test. - os.chdir(self._cwd) - # Cleanup temp files/directories created during tests. - for project_dir in self._temp_paths: - if os.path.exists(project_dir): - if os.path.isdir(project_dir): - shutil.rmtree(project_dir, True) - else: - os.remove(project_dir) - # Restore previous settings after each test. - settings._wrapped = self._wrapped - - def unique_name(self, string): - rnd_str = '____' + str(random.randint(1, 9999999)) - return __name__ + '-generated-' + string + rnd_str - - def assertElapsedLessThan(self, seconds): - elapsed = time.time() - self._start_time - self.assertTrue(elapsed < seconds, 'elapsed time of %0.3fs is greater than %0.3fs' % (elapsed, seconds)) - - @contextlib.contextmanager - def current_user(self, user_or_username, password=None): - try: - if isinstance(user_or_username, User): - username = user_or_username.username - else: - username = user_or_username - password = password or self._user_passwords.get(username) - previous_auth = self._current_auth - if username is None: - self._current_auth = None - else: - self._current_auth = (username, password) - yield - finally: - self._current_auth = previous_auth - - def make_user(self, username, password=None, super_user=False): - user = None - password = password or username - if super_user: - user = User.objects.create_superuser(username, "%s@example.com", password) - else: - user = User.objects.create_user(username, "%s@example.com", password) - self._user_passwords[user.username] = password - return user - - def make_organizations(self, created_by, count=1): - results = [] - for x in range(0, count): - results.append(self.make_organization(created_by=created_by, count=x)) - return results - - def make_organization(self, created_by, count=1): - self.object_ctr = self.object_ctr + 1 - return Organization.objects.create( - name="org%s-%s" % (count, self.object_ctr), description="org%s" % count, created_by=created_by - ) - - def make_project(self, name=None, description='', created_by=None, - playbook_content='', role_playbooks=None, unicode_prefix=True): - if not name: - name = self.unique_name('Project') - - if not os.path.exists(settings.PROJECTS_ROOT): - os.makedirs(settings.PROJECTS_ROOT) - # Create temp project directory. - if unicode_prefix: - tmp_prefix = u'\u2620tmp' - else: - tmp_prefix = 'tmp' - project_dir = tempfile.mkdtemp(prefix=tmp_prefix, dir=settings.PROJECTS_ROOT) - self._temp_paths.append(project_dir) - # Create temp playbook in project (if playbook content is given). - if playbook_content: - handle, playbook_path = tempfile.mkstemp(suffix=u'\u2620.yml', - dir=project_dir) - test_playbook_file = os.fdopen(handle, 'w') - test_playbook_file.write(playbook_content.encode('utf-8')) - test_playbook_file.close() - # Role playbooks are specified as a dict of role name and the - # content of tasks/main.yml playbook. - role_playbooks = role_playbooks or {} - for role_name, role_playbook_content in role_playbooks.items(): - role_tasks_dir = os.path.join(project_dir, 'roles', role_name, 'tasks') - if not os.path.exists(role_tasks_dir): - os.makedirs(role_tasks_dir) - role_tasks_playbook_path = os.path.join(role_tasks_dir, 'main.yml') - with open(role_tasks_playbook_path, 'w') as f: - f.write(role_playbook_content) - return Project.objects.create( - name=name, description=description, - local_path=os.path.basename(project_dir), created_by=created_by, - #scm_type='git', default_playbook='foo.yml', - ) - - def make_projects(self, created_by, count=1, playbook_content='', - role_playbooks=None, unicode_prefix=False): - results = [] - for x in range(0, count): - self.object_ctr = self.object_ctr + 1 - results.append(self.make_project( - name="proj%s-%s" % (x, self.object_ctr), - description=u"proj%s" % x, - created_by=created_by, - playbook_content=playbook_content, - role_playbooks=role_playbooks, - unicode_prefix=unicode_prefix - )) - return results - - def decide_created_by(self, created_by=None): - if created_by: - return created_by - if self.super_django_user: - return self.super_django_user - raise RuntimeError('please call setup_users() or specify a user') - - def make_inventory(self, organization=None, name=None, created_by=None): - created_by = self.decide_created_by(created_by) - if not organization: - organization = self.make_organization(created_by=created_by) - - return Inventory.objects.create(name=name or self.unique_name('Inventory'), organization=organization, created_by=created_by) - - def make_job_template(self, name=None, created_by=None, organization=None, inventory=None, project=None, playbook=None, **kwargs): - created_by = self.decide_created_by(created_by) - if not inventory: - inventory = self.make_inventory(organization=organization, created_by=created_by) - if not organization: - organization = inventory.organization - if not project: - project = self.make_project(self.unique_name('Project'), created_by=created_by, playbook_content=playbook if playbook else TEST_PLAYBOOK) - - if project and project.playbooks and len(project.playbooks) > 0: - playbook = project.playbooks[0] - else: - raise RuntimeError('Expected project to have at least one playbook') - - if project not in organization.projects.all(): - organization.projects.add(project) - - opts = { - 'name' : name or self.unique_name('JobTemplate'), - 'job_type': 'check', - 'inventory': inventory, - 'project': project, - 'host_config_key': settings.SYSTEM_UUID, - 'created_by': created_by, - 'playbook': playbook, - 'ask_credential_on_launch': True, - } - opts.update(kwargs) - return JobTemplate.objects.create(**opts) - - def make_job(self, job_template=None, created_by=None, inital_state='new', **kwargs): - created_by = self.decide_created_by(created_by) - if not job_template: - job_template = self.make_job_template(created_by=created_by) - - opts = { - 'created_by': created_by, - 'status': inital_state, - } - opts.update(kwargs) - return job_template.create_job(**opts) - - def make_credential(self, **kwargs): - opts = { - 'name': self.unique_name('Credential'), - 'kind': 'ssh', - 'user': self.super_django_user, - 'username': '', - 'ssh_key_data': '', - 'ssh_key_unlock': '', - 'password': '', - 'become_method': '', - 'become_username': '', - 'become_password': '', - 'vault_password': '', - } - opts.update(kwargs) - user = opts['user'] - del opts['user'] - cred = Credential.objects.create(**opts) - cred.admin_role.members.add(user) - return cred - - def setup_instances(self): - instance = Instance(uuid=settings.SYSTEM_UUID, hostname='127.0.0.1') - instance.save() - - def setup_users(self, just_super_user=False): - # Create a user. - self.super_username = 'admin' - self.super_password = 'admin' - self.normal_username = 'normal' - self.normal_password = 'normal' - self.other_username = 'other' - self.other_password = 'other' - self.nobody_username = 'nobody' - self.nobody_password = 'nobody' - - self.super_django_user = self.make_user(self.super_username, self.super_password, super_user=True) - - if not just_super_user: - self.normal_django_user = self.make_user(self.normal_username, self.normal_password, super_user=False) - self.other_django_user = self.make_user(self.other_username, self.other_password, super_user=False) - self.nobody_django_user = self.make_user(self.nobody_username, self.nobody_password, super_user=False) - - def get_super_credentials(self): - return (self.super_username, self.super_password) - - def get_normal_credentials(self): - return (self.normal_username, self.normal_password) - - def get_other_credentials(self): - return (self.other_username, self.other_password) - - def get_nobody_credentials(self): - # here is a user without any permissions... - return (self.nobody_username, self.nobody_password) - - def get_invalid_credentials(self): - return ('random', 'combination') - - def _generic_rest(self, url, data=None, expect=204, auth=None, method=None, - data_type=None, accept=None, remote_addr=None, - return_response_object=False, client_kwargs=None): - assert method is not None - method_name = method.lower() - client_kwargs = client_kwargs or {} - if accept: - client_kwargs['HTTP_ACCEPT'] = accept - if remote_addr is not None: - client_kwargs['REMOTE_ADDR'] = remote_addr - auth = auth or self._current_auth - if auth: - # Dict is only used to test case when both Authorization and - # X-Auth-Token headers are passed. - if isinstance(auth, dict): - basic = auth.get('basic', ()) - if basic: - basic_auth = base64.b64encode('%s:%s' % (basic[0], basic[1])) - basic_auth = basic_auth.decode('ascii') - client_kwargs['HTTP_AUTHORIZATION'] = 'Basic %s' % basic_auth - token = auth.get('token', '') - if token and not basic: - client_kwargs['HTTP_AUTHORIZATION'] = 'Token %s' % token - elif token: - client_kwargs['HTTP_X_AUTH_TOKEN'] = 'Token %s' % token - elif isinstance(auth, (list, tuple)): - #client.login(username=auth[0], password=auth[1]) - basic_auth = base64.b64encode('%s:%s' % (auth[0], auth[1])) - basic_auth = basic_auth.decode('ascii') - client_kwargs['HTTP_AUTHORIZATION'] = 'Basic %s' % basic_auth - elif isinstance(auth, basestring): - client_kwargs['HTTP_AUTHORIZATION'] = 'Token %s' % auth - client = Client(**client_kwargs) - method = getattr(client, method_name) - response = None - if method_name not in ('options', 'head', 'get', 'delete'): - data_type = data_type or 'json' - if data_type == 'json': - response = method(url, json.dumps(data), 'application/json') - elif data_type == 'yaml': - response = method(url, yaml.safe_dump(data), 'application/yaml') - elif data_type == 'form': - response = method(url, urllib.urlencode(data), 'application/x-www-form-urlencoded') - else: - self.fail('Unsupported data_type %s' % data_type) - else: - response = method(url) - - self.assertFalse(response.status_code == 500 and expect != 500, - 'Failed (500): %s' % force_text(response.content)) - if expect is not None: - assert response.status_code == expect, u"expected status %s, got %s for url=%s as auth=%s: %s" % ( - expect, response.status_code, url, auth, force_text(response.content) - ) - if method_name == 'head': - self.assertFalse(response.content) - if return_response_object: - return response - if response.status_code not in [204, 405] and method_name != 'head' and response.content: - # no JSON responses in these at least for now, 409 should probably return some (FIXME) - if response['Content-Type'].startswith('application/json'): - obj = json.loads(force_text(response.content)) - elif response['Content-Type'].startswith('application/yaml'): - obj = yaml.safe_load(force_text(response.content)) - elif response['Content-Type'].startswith('text/plain'): - obj = { - 'content': force_text(response.content) - } - elif response['Content-Type'].startswith('text/html'): - obj = { - 'content': force_text(response.content) - } - else: - self.fail('Unsupport response content type %s' % response['Content-Type']) - else: - obj = {} - - # Create a new subclass of object type and attach the response instance - # to it (to allow for checking response headers). - if isinstance(obj, dict): - return type('DICT', (dict,), {'response': response})(obj.items()) - elif isinstance(obj, (tuple, list)): - return type('LIST', (list,), {'response': response})(iter(obj)) - else: - return obj - - def options(self, url, expect=200, auth=None, accept=None, - remote_addr=None): - return self._generic_rest(url, data=None, expect=expect, auth=auth, - method='options', accept=accept, - remote_addr=remote_addr) - - def head(self, url, expect=200, auth=None, accept=None, remote_addr=None): - return self._generic_rest(url, data=None, expect=expect, auth=auth, - method='head', accept=accept, - remote_addr=remote_addr) - - def get(self, url, expect=200, auth=None, accept=None, remote_addr=None, client_kwargs={}): - return self._generic_rest(url, data=None, expect=expect, auth=auth, - method='get', accept=accept, - remote_addr=remote_addr, - client_kwargs=client_kwargs) - - def post(self, url, data, expect=204, auth=None, data_type=None, - accept=None, remote_addr=None, client_kwargs={}): - return self._generic_rest(url, data=data, expect=expect, auth=auth, - method='post', data_type=data_type, - accept=accept, - remote_addr=remote_addr, - client_kwargs=client_kwargs) - - def put(self, url, data, expect=200, auth=None, data_type=None, - accept=None, remote_addr=None): - return self._generic_rest(url, data=data, expect=expect, auth=auth, - method='put', data_type=data_type, - accept=accept, remote_addr=remote_addr) - - def patch(self, url, data, expect=200, auth=None, data_type=None, - accept=None, remote_addr=None): - return self._generic_rest(url, data=data, expect=expect, auth=auth, - method='patch', data_type=data_type, - accept=accept, remote_addr=remote_addr) - - def delete(self, url, expect=201, auth=None, data_type=None, accept=None, - remote_addr=None): - return self._generic_rest(url, data=None, expect=expect, auth=auth, - method='delete', accept=accept, - remote_addr=remote_addr) - - def get_urls(self, collection_url, auth=None): - # TODO: this test helper function doesn't support pagination - data = self.get(collection_url, expect=200, auth=auth) - return [item['url'] for item in data['results']] - - def check_invalid_auth(self, url, data=None, methods=None): - ''' - Check various methods of accessing the given URL with invalid - authentication credentials. - ''' - data = data or {} - methods = methods or ('options', 'head', 'get') - for auth in [(None,), ('invalid', 'password')]: - with self.current_user(*auth): - for method in methods: - f = getattr(self, method) - if method in ('post', 'put', 'patch'): - f(url, data, expect=401) - else: - f(url, expect=401) - - def check_pagination_and_size(self, data, desired_count, previous=False, - next=False): - self.assertTrue('results' in data) - self.assertEqual(data['count'], desired_count) - if previous: - self.assertTrue(data['previous']) - else: - self.assertFalse(data['previous']) - if next: - self.assertTrue(data['next']) - else: - self.assertFalse(data['next']) - - def check_list_ids(self, data, queryset, check_order=False): - data_ids = [x['id'] for x in data['results']] - qs_ids = queryset.values_list('pk', flat=True) - if check_order: - self.assertEqual(tuple(data_ids), tuple(qs_ids)) - else: - self.assertEqual(set(data_ids), set(qs_ids)) - - def check_get_list(self, url, user, qs, fields=None, expect=200, - check_order=False, offset=None, limit=None): - ''' - Check that the given list view URL returns results for the given user - that match the given queryset. - ''' - offset = offset or 0 - with self.current_user(user): - if expect == 400: - self.options(url, expect=200) - else: - self.options(url, expect=expect) - self.head(url, expect=expect) - response = self.get(url, expect=expect) - if expect != 200: - return - total = qs.count() - if limit is not None: - if limit > 0: - qs = qs[offset:offset + limit] - else: - qs = qs.none() - self.check_pagination_and_size(response, total, offset > 0, - limit and ((offset + limit) < total)) - self.check_list_ids(response, qs, check_order) - if fields: - for obj in response['results']: - returned_fields = set(obj.keys()) - expected_fields = set(fields) - msg = '' - not_expected = returned_fields - expected_fields - if not_expected: - msg += 'fields %s not expected ' % ', '.join(not_expected) - not_returned = expected_fields - returned_fields - if not_returned: - msg += 'fields %s not returned ' % ', '.join(not_returned) - self.assertTrue(set(obj.keys()) <= set(fields), msg) - - def check_not_found(self, string, substr, description=None, word_boundary=False): - if word_boundary: - count = len(re.findall(r'\b%s\b' % re.escape(substr), string)) - else: - count = string.find(substr) - if count == -1: - count = 0 - - msg = '' - if description: - msg = 'Test "%s".\n' % description - msg += '"%s" found in: "%s"' % (substr, string) - self.assertEqual(count, 0, msg) - - def check_found(self, string, substr, count=-1, description=None, word_boundary=False): - if word_boundary: - count_actual = len(re.findall(r'\b%s\b' % re.escape(substr), string)) - else: - count_actual = string.count(substr) - - msg = '' - if description: - msg = 'Test "%s".\n' % description - if count == -1: - self.assertTrue(count_actual > 0) - else: - msg += 'Found %d occurances of "%s" instead of %d in: "%s"' % (count_actual, substr, count, string) - self.assertEqual(count_actual, count, msg) - - def check_job_result(self, job, expected='successful', expect_stdout=True, - expect_traceback=False): - msg = u'job status is %s, expected %s' % (job.status, expected) - msg = u'%s\nargs:\n%s' % (msg, job.job_args) - msg = u'%s\nenv:\n%s' % (msg, job.job_env) - if job.result_traceback: - msg = u'%s\ngot traceback:\n%s' % (msg, job.result_traceback) - if job.result_stdout: - msg = u'%s\ngot stdout:\n%s' % (msg, job.result_stdout) - if isinstance(expected, (list, tuple)): - self.assertTrue(job.status in expected) - else: - self.assertEqual(job.status, expected, msg) - if expect_stdout: - self.assertTrue(job.result_stdout) - else: - self.assertTrue(job.result_stdout in ('', 'stdout capture is missing'), - u'expected no stdout, got:\n%s' % - job.result_stdout) - if expect_traceback: - self.assertTrue(job.result_traceback) - else: - self.assertFalse(job.result_traceback, - u'expected no traceback, got:\n%s' % - job.result_traceback) - - -class BaseTest(BaseTestMixin, django.test.TestCase): - ''' - Base class for unit tests. - ''' - - -class BaseTransactionTest(BaseTestMixin, django.test.TransactionTestCase): - ''' - Base class for tests requiring transactions (or where the test database - needs to be accessed by subprocesses). - ''' - - -@override_settings(CELERY_ALWAYS_EAGER=True, - CELERY_EAGER_PROPAGATES_EXCEPTIONS=True, - ANSIBLE_TRANSPORT='local') -class BaseLiveServerTest(BaseTestMixin, django.test.LiveServerTestCase): - ''' - Base class for tests requiring a live test server. - ''' - def setUp(self): - super(BaseLiveServerTest, self).setUp() - settings.INTERNAL_API_URL = self.live_server_url - - -@override_settings(CELERY_ALWAYS_EAGER=True, - CELERY_EAGER_PROPAGATES_EXCEPTIONS=True, - ANSIBLE_TRANSPORT='local', - DEBUG=True) -class BaseJobExecutionTest(BaseLiveServerTest): - ''' - Base class for celery task tests. - ''' diff --git a/awx/main/tests/functional/api/test_deprecated_credential_assignment.py b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py index e9090630e2..f6466affd7 100644 --- a/awx/main/tests/functional/api/test_deprecated_credential_assignment.py +++ b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py @@ -1,3 +1,4 @@ +import json import mock import pytest @@ -5,6 +6,14 @@ from awx.main.models import Credential, Job from awx.api.versioning import reverse +@pytest.fixture +def ec2_source(inventory, project): + with mock.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.update'): + return inventory.inventory_sources.create( + name='some_source', update_on_project_update=True, source='ec2', + source_project=project, scm_last_revision=project.scm_revision) + + @pytest.fixture def job_template(job_template, project, inventory): job_template.playbook = 'helloworld.yml' @@ -34,6 +43,14 @@ def test_ssh_credential_access(get, job_template, admin, machine_credential): assert resp.data['summary_fields']['credential']['kind'] == 'ssh' +@pytest.mark.django_db +@pytest.mark.parametrize('key', ('credential', 'vault_credential', 'cloud_credential', 'network_credential')) +def test_invalid_credential_update(get, patch, job_template, admin, key): + url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk, 'version': 'v1'}) + resp = patch(url, {key: 999999}, admin, expect=400) + assert 'Credential 999999 does not exist' in json.loads(resp.content)[key] + + @pytest.mark.django_db def test_ssh_credential_update(get, patch, job_template, admin, machine_credential): url = reverse('api:job_template_detail', kwargs={'pk': job_template.pk}) @@ -362,3 +379,18 @@ def test_rbac_default_credential_usage(get, post, job_template, alice, machine_c new_cred.use_role.members.add(alice) url = reverse('api:job_template_launch', kwargs={'pk': job_template.pk}) post(url, {'credential': new_cred.pk}, alice, expect=201) + + +@pytest.mark.django_db +def test_inventory_source_deprecated_credential(get, patch, admin, ec2_source, credential): + url = reverse('api:inventory_source_detail', kwargs={'pk': ec2_source.pk}) + patch(url, {'credential': credential.pk}, admin, expect=200) + resp = get(url, admin, expect=200) + assert json.loads(resp.content)['credential'] == credential.pk + + +@pytest.mark.django_db +def test_inventory_source_invalid_deprecated_credential(patch, admin, ec2_source, credential): + url = reverse('api:inventory_source_detail', kwargs={'pk': ec2_source.pk}) + resp = patch(url, {'credential': 999999}, admin, expect=400) + assert 'Credential 999999 does not exist' in resp.content diff --git a/awx/main/tests/functional/api/test_generic.py b/awx/main/tests/functional/api/test_generic.py index 4d68b43ead..f445ee73f7 100644 --- a/awx/main/tests/functional/api/test_generic.py +++ b/awx/main/tests/functional/api/test_generic.py @@ -91,3 +91,13 @@ class TestDeleteViews: job.get_absolute_url(), user=system_auditor ) assert resp.status_code == 403 + + +@pytest.mark.django_db +def test_non_filterable_field(options, instance, admin_user): + r = options( + url=instance.get_absolute_url(), + user=admin_user + ) + field_info = r.data['actions']['GET']['percent_capacity_remaining'] + assert 'filterable' in field_info diff --git a/awx/main/tests/functional/api/test_instance_group.py b/awx/main/tests/functional/api/test_instance_group.py index 038950927c..3dfd554f11 100644 --- a/awx/main/tests/functional/api/test_instance_group.py +++ b/awx/main/tests/functional/api/test_instance_group.py @@ -7,6 +7,13 @@ from awx.main.models import ( ) +@pytest.fixture +def tower_instance_group(): + ig = InstanceGroup(name='tower') + ig.save() + return ig + + @pytest.fixture def instance_group(job_factory): ig = InstanceGroup(name="east") @@ -15,8 +22,8 @@ def instance_group(job_factory): @pytest.fixture -def tower_instance_group(): - ig = InstanceGroup(name='tower') +def isolated_instance_group(instance_group): + ig = InstanceGroup(name="iso", controller=instance_group) ig.save() return ig @@ -80,12 +87,22 @@ def test_delete_instance_group_jobs_running(delete, instance_group_jobs_running, @pytest.mark.django_db -def test_delete_tower_instance_group_prevented(delete, options, tower_instance_group, user): +def test_modify_delete_tower_instance_group_prevented(delete, options, tower_instance_group, user, patch, put): url = reverse("api:instance_group_detail", kwargs={'pk': tower_instance_group.pk}) super_user = user('bob', True) + delete(url, None, super_user, expect=403) + resp = options(url, None, super_user, expect=200) - actions = ['GET', 'PUT',] + assert len(resp.data['actions'].keys()) == 2 assert 'DELETE' not in resp.data['actions'] - for action in actions: - assert action in resp.data['actions'] + assert 'GET' in resp.data['actions'] + assert 'PUT' in resp.data['actions'] + + +@pytest.mark.django_db +def test_prevent_delete_iso_and_control_groups(delete, isolated_instance_group, admin): + iso_url = reverse("api:instance_group_detail", kwargs={'pk': isolated_instance_group.pk}) + controller_url = reverse("api:instance_group_detail", kwargs={'pk': isolated_instance_group.controller.pk}) + delete(iso_url, None, admin, expect=403) + delete(controller_url, None, admin, expect=403) diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index af16dad584..3bd337f10f 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -13,6 +13,9 @@ from awx.main.migrations import _save_password_keys as save_password_keys from django.conf import settings from django.apps import apps +# DRF +from rest_framework.exceptions import ValidationError + @pytest.mark.django_db @pytest.mark.parametrize( @@ -113,6 +116,51 @@ def test_create_v1_rbac_check(get, post, project, credential, net_credential, ra post(reverse('api:job_template_list', kwargs={'version': 'v1'}), base_kwargs, rando, expect=403) +# TODO: remove as each field tested has support removed +@pytest.mark.django_db +def test_jt_deprecated_summary_fields( + project, inventory, + machine_credential, net_credential, vault_credential, + mocker): + jt = JobTemplate.objects.create( + project=project, + inventory=inventory, + playbook='helloworld.yml' + ) + + class MockView: + kwargs = {} + request = None + + class MockRequest: + version = 'v1' + user = None + + view = MockView() + request = MockRequest() + view.request = request + serializer = JobTemplateSerializer(instance=jt, context={'view': view, 'request': request}) + + for kwargs in [{}, {'pk': 1}]: # detail vs. list view + for version in ['v1', 'v2']: + view.kwargs = kwargs + request.version = version + sf = serializer.get_summary_fields(jt) + assert 'credential' not in sf + assert 'vault_credential' not in sf + + jt.credentials.add(machine_credential, net_credential, vault_credential) + + view.kwargs = {'pk': 1} + for version in ['v1', 'v2']: + request.version = version + sf = serializer.get_summary_fields(jt) + assert 'credential' in sf + assert sf['credential'] # not empty dict + assert 'vault_credential' in sf + assert sf['vault_credential'] + + @pytest.mark.django_db def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws): objs = organization_factory("org", superusers=['admin']) @@ -615,3 +663,16 @@ def test_job_template_unset_custom_virtualenv(get, patch, organization_factory, url = reverse('api:job_template_detail', kwargs={'pk': jt.id}) resp = patch(url, {'custom_virtualenv': value}, user=objs.superusers.admin, expect=200) assert resp.data['custom_virtualenv'] is None + + +@pytest.mark.django_db +def test_callback_disallowed_null_inventory(project): + jt = JobTemplate.objects.create( + name='test-jt', inventory=None, + ask_inventory_on_launch=True, + project=project, playbook='helloworld.yml') + serializer = JobTemplateSerializer(jt) + assert serializer.instance == jt + with pytest.raises(ValidationError) as exc: + serializer.validate({'host_config_key': 'asdfbasecfeee'}) + assert 'Cannot enable provisioning callback without an inventory set' in str(exc) diff --git a/awx/main/tests/functional/api/test_oauth.py b/awx/main/tests/functional/api/test_oauth.py index 15362e71be..4110701e6a 100644 --- a/awx/main/tests/functional/api/test_oauth.py +++ b/awx/main/tests/functional/api/test_oauth.py @@ -1,6 +1,9 @@ import pytest import base64 +from django.db import connection + +from awx.main.utils.encryption import decrypt_value, get_encryption_key from awx.api.versioning import reverse, drf_reverse from awx.main.models.oauth import (OAuth2Application as Application, OAuth2AccessToken as AccessToken, @@ -19,44 +22,42 @@ def test_personal_access_token_creation(oauth_application, post, alice): oauth_application.client_id, oauth_application.client_secret ])) ) - resp_json = resp._container[0] assert 'access_token' in resp_json assert 'scope' in resp_json assert 'refresh_token' in resp_json - + @pytest.mark.django_db -def test_oauth_application_create(admin, post): +def test_oauth_application_create(admin, organization, post): response = post( reverse('api:o_auth2_application_list'), { 'name': 'test app', - 'user': admin.pk, + 'organization': organization.pk, 'client_type': 'confidential', 'authorization_grant_type': 'password', }, admin, expect=201 ) assert 'modified' in response.data assert 'updated' not in response.data - assert 'user' in response.data['related'] created_app = Application.objects.get(client_id=response.data['client_id']) assert created_app.name == 'test app' - assert created_app.user == admin assert created_app.skip_authorization is False assert created_app.redirect_uris == '' assert created_app.client_type == 'confidential' assert created_app.authorization_grant_type == 'password' + assert created_app.organization == organization @pytest.mark.django_db -def test_oauth_application_update(oauth_application, patch, admin, alice): +def test_oauth_application_update(oauth_application, organization, patch, admin, alice): patch( reverse('api:o_auth2_application_detail', kwargs={'pk': oauth_application.pk}), { 'name': 'Test app with immutable grant type and user', + 'organization': organization.pk, 'redirect_uris': 'http://localhost/api/', 'authorization_grant_type': 'implicit', 'skip_authorization': True, - 'user': alice.pk, }, admin, expect=200 ) updated_app = Application.objects.get(client_id=oauth_application.client_id) @@ -64,7 +65,27 @@ def test_oauth_application_update(oauth_application, patch, admin, alice): assert updated_app.redirect_uris == 'http://localhost/api/' assert updated_app.skip_authorization is True assert updated_app.authorization_grant_type == 'password' - assert updated_app.user == admin + assert updated_app.organization == organization + + +@pytest.mark.django_db +def test_oauth_application_encryption(admin, organization, post): + response = post( + reverse('api:o_auth2_application_list'), { + 'name': 'test app', + 'organization': organization.pk, + 'client_type': 'confidential', + 'authorization_grant_type': 'password', + }, admin, expect=201 + ) + pk = response.data.get('id') + secret = response.data.get('client_secret') + with connection.cursor() as cursor: + encrypted = cursor.execute( + 'SELECT client_secret FROM main_oauth2application WHERE id={}'.format(pk) + ).fetchone()[0] + assert encrypted.startswith('$encrypted$') + assert decrypt_value(get_encryption_key('value', pk=None), encrypted) == secret @pytest.mark.django_db @@ -94,7 +115,7 @@ def test_oauth_token_create(oauth_application, get, post, admin): ) assert response.data['summary_fields']['tokens']['count'] == 1 assert response.data['summary_fields']['tokens']['results'][0] == { - 'id': token.pk, 'scope': token.scope, 'token': '**************' + 'id': token.pk, 'scope': token.scope, 'token': '************' } diff --git a/awx/main/tests/functional/api/test_organizations.py b/awx/main/tests/functional/api/test_organizations.py index 9ec6787d53..43a9ffb1e5 100644 --- a/awx/main/tests/functional/api/test_organizations.py +++ b/awx/main/tests/functional/api/test_organizations.py @@ -131,7 +131,7 @@ def test_organization_inventory_list(organization, inventory_factory, get, alice assert get(reverse('api:organization_inventories_list', kwargs={'pk': organization.id}), user=alice).data['count'] == 2 assert get(reverse('api:organization_inventories_list', kwargs={'pk': organization.id}), user=bob).data['count'] == 1 get(reverse('api:organization_inventories_list', kwargs={'pk': organization.id}), user=rando, expect=403) - + @pytest.mark.django_db @mock.patch('awx.api.views.feature_enabled', lambda feature: True) diff --git a/awx/main/tests/functional/api/test_rbac_displays.py b/awx/main/tests/functional/api/test_rbac_displays.py index c0931b50da..822456b92b 100644 --- a/awx/main/tests/functional/api/test_rbac_displays.py +++ b/awx/main/tests/functional/api/test_rbac_displays.py @@ -3,8 +3,8 @@ import pytest from awx.api.versioning import reverse from django.test.client import RequestFactory -from awx.main.models import Role, Group, UnifiedJobTemplate, JobTemplate -from awx.main.access import access_registry +from awx.main.models import Role, Group, UnifiedJobTemplate, JobTemplate, WorkflowJobTemplate +from awx.main.access import access_registry, WorkflowJobTemplateAccess from awx.main.utils import prefetch_page_capabilities from awx.api.serializers import JobTemplateSerializer, UnifiedJobTemplateSerializer @@ -196,12 +196,6 @@ class TestAccessListCapabilities: direct_access_list = response.data['results'][0]['summary_fields']['direct_access'] assert direct_access_list[0]['role']['user_capabilities']['unattach'] == 'foobar' - def test_user_access_list_direct_access_capability(self, rando, get): - "When a user views their own access list, they cannot unattach their admin role" - response = get(reverse('api:user_access_list', kwargs={'pk': rando.id}), rando) - direct_access_list = response.data['results'][0]['summary_fields']['direct_access'] - assert not direct_access_list[0]['role']['user_capabilities']['unattach'] - @pytest.mark.django_db def test_team_roles_unattach(mocker, team, team_member, inventory, mock_access_method, get): @@ -322,6 +316,17 @@ def test_prefetch_jt_copy_capability(job_template, project, inventory, rando): assert mapping[job_template.id] == {'copy': True} +@pytest.mark.django_db +def test_workflow_orphaned_capabilities(rando): + wfjt = WorkflowJobTemplate.objects.create(name='test', organization=None) + wfjt.admin_role.members.add(rando) + access = WorkflowJobTemplateAccess(rando) + assert not access.get_user_capabilities( + wfjt, method_list=['edit', 'copy'], + capabilities_cache={'copy': True} + )['copy'] + + @pytest.mark.django_db def test_manual_projects_no_update(manual_project, get, admin_user): response = get(reverse('api:project_detail', kwargs={'pk': manual_project.pk}), admin_user, expect=200) diff --git a/awx/main/tests/functional/api/test_unified_jobs_view.py b/awx/main/tests/functional/api/test_unified_jobs_view.py index d4dd4bd53a..391cb57d18 100644 --- a/awx/main/tests/functional/api/test_unified_jobs_view.py +++ b/awx/main/tests/functional/api/test_unified_jobs_view.py @@ -2,7 +2,7 @@ import pytest from awx.api.versioning import reverse from awx.main.models import UnifiedJob, ProjectUpdate, InventoryUpdate -from awx.main.tests.base import URI +from awx.main.tests.URI import URI from awx.main.constants import ACTIVE_STATES diff --git a/awx/main/tests/functional/models/test_activity_stream.py b/awx/main/tests/functional/models/test_activity_stream.py index 7253999fb7..f13ce48f20 100644 --- a/awx/main/tests/functional/models/test_activity_stream.py +++ b/awx/main/tests/functional/models/test_activity_stream.py @@ -16,6 +16,7 @@ from awx.main.models import ( # other AWX from awx.main.utils import model_to_dict +from awx.main.utils.common import get_allowed_fields from awx.api.serializers import InventorySourceSerializer # Django @@ -181,3 +182,20 @@ def test_annon_user_action(): inv = Inventory.objects.create(name='ainventory') entry = inv.activitystream_set.filter(operation='create').first() assert not entry.actor + + +@pytest.mark.django_db +def test_modified_not_allowed_field(somecloud_type): + ''' + If this test fails, that means that read-only fields are showing + up in the activity stream serialization of an instance. + + That _probably_ means that you just connected a new model to the + activity_stream_registrar, but did not add its serializer to + the model->serializer mapping. + ''' + from awx.main.signals import model_serializer_mapping + from awx.main.registrar import activity_stream_registrar + + for Model in activity_stream_registrar.models: + assert 'modified' not in get_allowed_fields(Model(), model_serializer_mapping), Model diff --git a/awx/main/tests/functional/models/test_job.py b/awx/main/tests/functional/models/test_job.py index bc166fd77d..ec23045fea 100644 --- a/awx/main/tests/functional/models/test_job.py +++ b/awx/main/tests/functional/models/test_job.py @@ -1,6 +1,7 @@ import pytest from awx.main.models import JobTemplate, Job +from crum import impersonate @pytest.mark.django_db @@ -49,3 +50,18 @@ def test_awx_custom_virtualenv_without_jt(project): job = Job.objects.get(pk=job.id) assert job.ansible_virtualenv_path == '/venv/fancy-proj' + + +@pytest.mark.django_db +def test_update_parent_instance(job_template, alice): + # jobs are launched as a particular user, user not saved as modified_by + with impersonate(alice): + assert job_template.current_job is None + assert job_template.status == 'never updated' + assert job_template.modified_by is None + job = job_template.jobs.create(status='new') + job.status = 'pending' + job.save() + assert job_template.current_job == job + assert job_template.status == 'pending' + assert job_template.modified_by is None diff --git a/awx/main/tests/functional/task_management/test_rampart_groups.py b/awx/main/tests/functional/task_management/test_rampart_groups.py index 9b4b3eac44..ce79b78003 100644 --- a/awx/main/tests/functional/task_management/test_rampart_groups.py +++ b/awx/main/tests/functional/task_management/test_rampart_groups.py @@ -2,7 +2,7 @@ import pytest import mock from datetime import timedelta from awx.main.scheduler import TaskManager -from awx.main.models import InstanceGroup +from awx.main.models import InstanceGroup, WorkflowJob from awx.main.tasks import apply_cluster_membership_policies @@ -77,6 +77,18 @@ def test_multi_group_with_shared_dependency(instance_factory, default_instance_g assert TaskManager.start_task.call_count == 2 +@pytest.mark.django_db +def test_workflow_job_no_instancegroup(workflow_job_template_factory, default_instance_group, mocker): + wfjt = workflow_job_template_factory('anicedayforawalk').workflow_job_template + wfj = WorkflowJob.objects.create(workflow_job_template=wfjt) + wfj.status = "pending" + wfj.save() + with mocker.patch("awx.main.scheduler.TaskManager.start_task"): + TaskManager().schedule() + TaskManager.start_task.assert_called_once_with(wfj, None, []) + assert wfj.instance_group is None + + @pytest.mark.django_db def test_overcapacity_blocking_other_groups_unaffected(instance_factory, default_instance_group, mocker, instance_group_factory, job_template_factory): diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 37609cd222..d445508b71 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -56,98 +56,6 @@ def test_cloud_kind_uniqueness(): assert CredentialType.defaults['aws']().unique_by_kind is False -@pytest.mark.django_db -@pytest.mark.parametrize('input_, valid', [ - ({}, True), - ({'fields': []}, True), - ({'fields': {}}, False), - ({'fields': 123}, False), - ({'fields': [{'id': 'username', 'label': 'Username', 'foo': 'bar'}]}, False), - ({'fields': [{'id': 'username', 'label': 'Username'}]}, True), - ({'fields': [{'id': 'username', 'label': 'Username', 'type': 'string'}]}, True), - ({'fields': [{'id': 'username', 'label': 'Username', 'help_text': 1}]}, False), - ({'fields': [{'id': 'username', 'label': 'Username', 'help_text': 'Help Text'}]}, True), # noqa - ({'fields': [{'id': 'username', 'label': 'Username'}, {'id': 'username', 'label': 'Username 2'}]}, False), # noqa - ({'fields': [{'id': '$invalid$', 'label': 'Invalid', 'type': 'string'}]}, False), # noqa - ({'fields': [{'id': 'password', 'label': 'Password', 'type': 'invalid-type'}]}, False), - ({'fields': [{'id': 'ssh_key', 'label': 'SSH Key', 'type': 'string', 'format': 'ssh_private_key'}]}, True), # noqa - ({'fields': [{'id': 'flag', 'label': 'Some Flag', 'type': 'boolean'}]}, True), - ({'fields': [{'id': 'flag', 'label': 'Some Flag', 'type': 'boolean', 'choices': ['a', 'b']}]}, False), - ({'fields': [{'id': 'flag', 'label': 'Some Flag', 'type': 'boolean', 'secret': True}]}, False), - ({'fields': [{'id': 'certificate', 'label': 'Cert', 'multiline': True}]}, True), - ({'fields': [{'id': 'certificate', 'label': 'Cert', 'multiline': True, 'type': 'boolean'}]}, False), # noqa - ({'fields': [{'id': 'certificate', 'label': 'Cert', 'multiline': 'bad'}]}, False), # noqa - ({'fields': [{'id': 'token', 'label': 'Token', 'secret': True}]}, True), - ({'fields': [{'id': 'token', 'label': 'Token', 'secret': 'bad'}]}, False), - ({'fields': [{'id': 'token', 'label': 'Token', 'ask_at_runtime': True}]}, True), - ({'fields': [{'id': 'token', 'label': 'Token', 'ask_at_runtime': 'bad'}]}, False), # noqa - ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': 'not-a-list'}]}, False), # noqa - ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': []}]}, False), - ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': ['su', 'sudo']}]}, True), # noqa - ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': ['dup', 'dup']}]}, False), # noqa - ({'fields': [{'id': 'tower', 'label': 'Reserved!', }]}, False), # noqa -]) -def test_cred_type_input_schema_validity(input_, valid): - type_ = CredentialType( - kind='cloud', - name='SomeCloud', - managed_by_tower=True, - inputs=input_ - ) - if valid is False: - with pytest.raises(Exception) as e: - type_.full_clean() - assert e.type in (ValidationError, serializers.ValidationError) - else: - type_.full_clean() - - -@pytest.mark.django_db -@pytest.mark.parametrize('injectors, valid', [ - ({}, True), - ({'invalid-injector': {}}, False), - ({'file': 123}, False), - ({'file': {}}, True), - ({'file': {'template': '{{username}}'}}, True), - ({'file': {'template.username': '{{username}}'}}, True), - ({'file': {'template.username': '{{username}}', 'template.password': '{{pass}}'}}, True), - ({'file': {'template': '{{username}}', 'template.password': '{{pass}}'}}, False), - ({'file': {'foo': 'bar'}}, False), - ({'env': 123}, False), - ({'env': {}}, True), - ({'env': {'AWX_SECRET': '{{awx_secret}}'}}, True), - ({'env': {'AWX_SECRET_99': '{{awx_secret}}'}}, True), - ({'env': {'99': '{{awx_secret}}'}}, False), - ({'env': {'AWX_SECRET=': '{{awx_secret}}'}}, False), - ({'extra_vars': 123}, False), - ({'extra_vars': {}}, True), - ({'extra_vars': {'hostname': '{{host}}'}}, True), - ({'extra_vars': {'hostname_99': '{{host}}'}}, True), - ({'extra_vars': {'99': '{{host}}'}}, False), - ({'extra_vars': {'99=': '{{host}}'}}, False), -]) -def test_cred_type_injectors_schema(injectors, valid): - type_ = CredentialType( - kind='cloud', - name='SomeCloud', - managed_by_tower=True, - inputs={ - 'fields': [ - {'id': 'username', 'type': 'string', 'label': '_'}, - {'id': 'pass', 'type': 'string', 'label': '_'}, - {'id': 'awx_secret', 'type': 'string', 'label': '_'}, - {'id': 'host', 'type': 'string', 'label': '_'}, - ] - }, - injectors=injectors - ) - if valid is False: - with pytest.raises(ValidationError): - type_.full_clean() - else: - type_.full_clean() - - @pytest.mark.django_db def test_credential_creation(organization_factory): org = organization_factory('test').organization @@ -174,49 +82,6 @@ def test_credential_creation(organization_factory): assert cred.inputs['username'] == cred.username == 'bob' -@pytest.mark.django_db -@pytest.mark.parametrize('inputs', [ - ['must-be-a-dict'], - {'user': 'wrong-key'}, - {'username': 1}, - {'username': 1.5}, - {'username': ['a', 'b', 'c']}, - {'username': {'a': 'b'}}, - {'username': False}, - {'flag': 1}, - {'flag': 1.5}, - {'flag': ['a', 'b', 'c']}, - {'flag': {'a': 'b'}}, - {'flag': 'some-string'}, -]) -def test_credential_creation_validation_failure(organization_factory, inputs): - org = organization_factory('test').organization - type_ = CredentialType( - kind='cloud', - name='SomeCloud', - managed_by_tower=True, - inputs={ - 'fields': [{ - 'id': 'username', - 'label': 'Username for SomeCloud', - 'type': 'string' - },{ - 'id': 'flag', - 'label': 'Some Boolean Flag', - 'type': 'boolean' - }] - } - ) - type_.save() - - with pytest.raises(Exception) as e: - cred = Credential(credential_type=type_, name="Bob's Credential", - inputs=inputs, organization=org) - cred.save() - cred.full_clean() - assert e.type in (ValidationError, serializers.ValidationError) - - @pytest.mark.django_db @pytest.mark.parametrize('kind', ['ssh', 'net', 'scm']) @pytest.mark.parametrize('ssh_key_data, ssh_key_unlock, valid', [ diff --git a/awx/main/tests/functional/test_instances.py b/awx/main/tests/functional/test_instances.py index 11484dfc6e..91dee86b9e 100644 --- a/awx/main/tests/functional/test_instances.py +++ b/awx/main/tests/functional/test_instances.py @@ -60,6 +60,21 @@ def test_policy_instance_few_instances(mock, instance_factory, instance_group_fa assert i2 in ig_4.instances.all() +@pytest.mark.django_db +@mock.patch('awx.main.tasks.handle_ha_toplogy_changes', return_value=None) +def test_policy_instance_distribution_round_up(mock, instance_factory, instance_group_factory): + i1 = instance_factory("i1") + i2 = instance_factory("i2") + i3 = instance_factory("i3") + i4 = instance_factory("i4") + i5 = instance_factory("i5") + ig_1 = instance_group_factory("ig1", percentage=79) + apply_cluster_membership_policies() + assert len(ig_1.instances.all()) == 4 + assert set([i1, i2, i3, i4]) == set(ig_1.instances.all()) + assert i5 not in ig_1.instances.all() + + @pytest.mark.django_db @mock.patch('awx.main.tasks.handle_ha_toplogy_changes', return_value=None) def test_policy_instance_distribution_uneven(mock, instance_factory, instance_group_factory): diff --git a/awx/main/tests/functional/test_rbac_api.py b/awx/main/tests/functional/test_rbac_api.py index a390b4c54f..c4114a81b0 100644 --- a/awx/main/tests/functional/test_rbac_api.py +++ b/awx/main/tests/functional/test_rbac_api.py @@ -56,7 +56,6 @@ def test_get_roles_list_user(organization, inventory, team, get, user): assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id in role_hash assert organization.admin_role.id in role_hash assert organization.member_role.id in role_hash - assert this_user.admin_role.id in role_hash assert custom_role.id in role_hash assert inventory.admin_role.id not in role_hash @@ -99,12 +98,12 @@ def test_cant_create_role(post, admin): @pytest.mark.django_db -def test_cant_delete_role(delete, admin): +def test_cant_delete_role(delete, admin, inventory): "Ensure we can't delete roles through the api" # Some day we might want to do this, but until that is speced out, lets # ensure we don't slip up and allow this implicitly through some helper or # another - response = delete(reverse('api:role_detail', kwargs={'pk': admin.admin_role.id}), admin) + response = delete(reverse('api:role_detail', kwargs={'pk': inventory.admin_role.id}), admin) assert response.status_code == 405 diff --git a/awx/main/tests/functional/test_rbac_inventory.py b/awx/main/tests/functional/test_rbac_inventory.py index 3258b9d0ae..508b2e0773 100644 --- a/awx/main/tests/functional/test_rbac_inventory.py +++ b/awx/main/tests/functional/test_rbac_inventory.py @@ -32,25 +32,40 @@ def test_custom_inv_script_access(organization, user): assert ou in custom_inv.admin_role -@pytest.mark.django_db -def test_modify_inv_script_foreign_org_admin(org_admin, organization, organization_factory, project): - custom_inv = CustomInventoryScript.objects.create(name='test', script='test', description='test', - organization=organization) +@pytest.fixture +def custom_inv(organization): + return CustomInventoryScript.objects.create( + name='test', script='test', description='test', organization=organization) + +@pytest.mark.django_db +def test_modify_inv_script_foreign_org_admin( + org_admin, organization, organization_factory, project, custom_inv): other_org = organization_factory('not-my-org').organization access = CustomInventoryScriptAccess(org_admin) assert not access.can_change(custom_inv, {'organization': other_org.pk, 'name': 'new-project'}) @pytest.mark.django_db -def test_org_member_inventory_script_permissions(org_member, organization): - custom_inv = CustomInventoryScript.objects.create(name='test', script='test', organization=organization) +def test_org_member_inventory_script_permissions(org_member, organization, custom_inv): access = CustomInventoryScriptAccess(org_member) assert access.can_read(custom_inv) assert not access.can_delete(custom_inv) assert not access.can_change(custom_inv, {'name': 'ed-test'}) +@pytest.mark.django_db +def test_copy_only_admin(org_member, organization, custom_inv): + custom_inv.admin_role.members.add(org_member) + access = CustomInventoryScriptAccess(org_member) + assert not access.can_copy(custom_inv) + assert access.get_user_capabilities(custom_inv, method_list=['edit', 'delete', 'copy']) == { + 'edit': True, + 'delete': True, + 'copy': False + } + + @pytest.mark.django_db @pytest.mark.parametrize("role", ["admin_role", "inventory_admin_role"]) def test_access_admin(role, organization, inventory, user): diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index a12bc31b70..0b9d0c46bd 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -11,6 +11,7 @@ from awx.main.access import ( ScheduleAccess ) from awx.main.models.jobs import JobTemplate +from awx.main.models.organization import Organization from awx.main.models.schedules import Schedule @@ -296,3 +297,30 @@ class TestJobTemplateSchedules: mock_change.return_value = True assert access.can_change(schedule, {'inventory': 42}) mock_change.assert_called_once_with(schedule, {'inventory': 42}) + + +@pytest.mark.django_db +def test_jt_org_ownership_change(user, jt_linked): + admin1 = user('admin1') + org1 = jt_linked.project.organization + org1.admin_role.members.add(admin1) + a1_access = JobTemplateAccess(admin1) + + assert a1_access.can_read(jt_linked) + + + admin2 = user('admin2') + org2 = Organization.objects.create(name='mrroboto', description='domo') + org2.admin_role.members.add(admin2) + a2_access = JobTemplateAccess(admin2) + + assert not a2_access.can_read(jt_linked) + + + jt_linked.project.organization = org2 + jt_linked.project.save() + jt_linked.inventory.organization = org2 + jt_linked.inventory.save() + + assert a2_access.can_read(jt_linked) + assert not a1_access.can_read(jt_linked) diff --git a/awx/main/tests/functional/test_rbac_oauth.py b/awx/main/tests/functional/test_rbac_oauth.py index 4aabd74f1e..35b915f94d 100644 --- a/awx/main/tests/functional/test_rbac_oauth.py +++ b/awx/main/tests/functional/test_rbac_oauth.py @@ -3,114 +3,250 @@ import pytest from awx.main.access import ( OAuth2ApplicationAccess, OAuth2TokenAccess, + ActivityStreamAccess, ) from awx.main.models.oauth import ( OAuth2Application as Application, OAuth2AccessToken as AccessToken, ) +from awx.main.models import ActivityStream from awx.api.versioning import reverse @pytest.mark.django_db -class TestOAuthApplication: - - @pytest.mark.parametrize("user_for_access, can_access_list", [ - (0, [True, True, True, True]), - (1, [False, True, True, False]), - (2, [False, False, True, False]), - (3, [False, False, False, True]), - ]) - def test_can_read_change_delete( - self, admin, org_admin, org_member, alice, user_for_access, can_access_list - ): - user_list = [admin, org_admin, org_member, alice] - access = OAuth2ApplicationAccess(user_list[user_for_access]) - for user, can_access in zip(user_list, can_access_list): +class TestOAuth2Application: + + @pytest.mark.parametrize("user_for_access, can_access_list", [ + (0, [True, True]), + (1, [True, True]), + (2, [True, True]), + (3, [False, False]), + ]) + def test_can_read( + self, admin, org_admin, org_member, alice, user_for_access, can_access_list, organization + ): + user_list = [admin, org_admin, org_member, alice] + access = OAuth2ApplicationAccess(user_list[user_for_access]) + app_creation_user_list = [admin, org_admin] + for user, can_access in zip(app_creation_user_list, can_access_list): + app = Application.objects.create( + name='test app for {}'.format(user.username), user=user, + client_type='confidential', authorization_grant_type='password', organization=organization + ) + assert access.can_read(app) is can_access + + + def test_app_activity_stream(self, org_admin, alice, organization): app = Application.objects.create( - name='test app for {}'.format(user.username), user=user, - client_type='confidential', authorization_grant_type='password' + name='test app for {}'.format(org_admin.username), user=org_admin, + client_type='confidential', authorization_grant_type='password', organization=organization ) - assert access.can_read(app) is can_access - assert access.can_change(app, {}) is can_access - assert access.can_delete(app) is can_access + access = OAuth2ApplicationAccess(org_admin) + assert access.can_read(app) is True + access = ActivityStreamAccess(org_admin) + activity_stream = ActivityStream.objects.filter(o_auth2_application=app).latest('pk') + assert access.can_read(activity_stream) is True + access = ActivityStreamAccess(alice) + assert access.can_read(app) is False + assert access.can_read(activity_stream) is False + - def test_superuser_can_always_create(self, admin, org_admin, org_member, alice): - access = OAuth2ApplicationAccess(admin) - for user in [admin, org_admin, org_member, alice]: - assert access.can_add({ - 'name': 'test app', 'user': user.pk, 'client_type': 'confidential', - 'authorization_grant_type': 'password' - }) - - def test_normal_user_cannot_create(self, admin, org_admin, org_member, alice): - for access_user in [org_member, alice]: - access = OAuth2ApplicationAccess(access_user) - for user in [admin, org_admin, org_member, alice]: - assert not access.can_add({ - 'name': 'test app', 'user': user.pk, 'client_type': 'confidential', - 'authorization_grant_type': 'password' - }) - - def test_org_admin_can_create_in_org(self, admin, org_admin, org_member, alice): - access = OAuth2ApplicationAccess(org_admin) - for user in [admin, alice]: - assert not access.can_add({ - 'name': 'test app', 'user': user.pk, 'client_type': 'confidential', - 'authorization_grant_type': 'password' - }) - for user in [org_admin, org_member]: - assert access.can_add({ - 'name': 'test app', 'user': user.pk, 'client_type': 'confidential', - 'authorization_grant_type': 'password' - }) - - -@pytest.mark.skip(reason="Needs Update - CA") -@pytest.mark.django_db -class TestOAuthToken: - - @pytest.mark.parametrize("user_for_access, can_access_list", [ - (0, [True, True, True, True]), - (1, [False, True, True, False]), - (2, [False, False, True, False]), - (3, [False, False, False, True]), - ]) - def test_can_read_change_delete( - self, post, admin, org_admin, org_member, alice, user_for_access, can_access_list - ): - user_list = [admin, org_admin, org_member, alice] - access = OAuth2TokenAccess(user_list[user_for_access]) - for user, can_access in zip(user_list, can_access_list): + def test_token_activity_stream(self, org_admin, alice, organization, post): app = Application.objects.create( - name='test app for {}'.format(user.username), user=user, - client_type='confidential', authorization_grant_type='password' + name='test app for {}'.format(org_admin.username), user=org_admin, + client_type='confidential', authorization_grant_type='password', organization=organization ) response = post( reverse('api:o_auth2_application_token_list', kwargs={'pk': app.pk}), - {'scope': 'read'}, admin, expect=201 + {'scope': 'read'}, org_admin, expect=201 ) token = AccessToken.objects.get(token=response.data['token']) + access = OAuth2ApplicationAccess(org_admin) + assert access.can_read(app) is True + access = ActivityStreamAccess(org_admin) + activity_stream = ActivityStream.objects.filter(o_auth2_access_token=token).latest('pk') + assert access.can_read(activity_stream) is True + access = ActivityStreamAccess(alice) + assert access.can_read(token) is False + assert access.can_read(activity_stream) is False - assert access.can_read(token) is can_access # TODO: fix this test + + + def test_can_edit_delete_app_org_admin( + self, admin, org_admin, org_member, alice, organization + ): + user_list = [admin, org_admin, org_member, alice] + can_access_list = [True, True, False, False] + for user, can_access in zip(user_list, can_access_list): + app = Application.objects.create( + name='test app for {}'.format(org_admin.username), user=org_admin, + client_type='confidential', authorization_grant_type='password', organization=organization + ) + access = OAuth2ApplicationAccess(user) + assert access.can_change(app, {}) is can_access + assert access.can_delete(app) is can_access + + + def test_can_edit_delete_app_admin( + self, admin, org_admin, org_member, alice, organization + ): + user_list = [admin, org_admin, org_member, alice] + can_access_list = [True, True, False, False] + for user, can_access in zip(user_list, can_access_list): + app = Application.objects.create( + name='test app for {}'.format(admin.username), user=admin, + client_type='confidential', authorization_grant_type='password', organization=organization + ) + access = OAuth2ApplicationAccess(user) + assert access.can_change(app, {}) is can_access + assert access.can_delete(app) is can_access + + + def test_superuser_can_always_create(self, admin, org_admin, org_member, alice): + access = OAuth2ApplicationAccess(admin) + for user in [admin, org_admin, org_member, alice]: + assert access.can_add({ + 'name': 'test app', 'user': user.pk, 'client_type': 'confidential', + 'authorization_grant_type': 'password', 'organization': 1 + }) + + def test_normal_user_cannot_create(self, admin, org_admin, org_member, alice): + for access_user in [org_member, alice]: + access = OAuth2ApplicationAccess(access_user) + for user in [admin, org_admin, org_member, alice]: + assert not access.can_add({ + 'name': 'test app', 'user': user.pk, 'client_type': 'confidential', + 'authorization_grant_type': 'password', 'organization': 1 + }) + + +@pytest.mark.django_db +class TestOAuth2Token: + + def test_can_read_change_delete_app_token( + self, post, admin, org_admin, org_member, alice, organization + ): + user_list = [admin, org_admin, org_member, alice] + can_access_list = [True, True, False, False] + app = Application.objects.create( + name='test app for {}'.format(admin.username), user=admin, + client_type='confidential', authorization_grant_type='password', + organization=organization + ) + response = post( + reverse('api:o_auth2_application_token_list', kwargs={'pk': app.pk}), + {'scope': 'read'}, admin, expect=201 + ) + for user, can_access in zip(user_list, can_access_list): + token = AccessToken.objects.get(token=response.data['token']) + access = OAuth2TokenAccess(user) + assert access.can_read(token) is can_access assert access.can_change(token, {}) is can_access assert access.can_delete(token) is can_access + + def test_auditor_can_read( + self, post, admin, org_admin, org_member, alice, system_auditor, organization + ): + user_list = [admin, org_admin, org_member] + can_access_list = [True, True, True] + cannot_access_list = [False, False, False] + app = Application.objects.create( + name='test app for {}'.format(admin.username), user=admin, + client_type='confidential', authorization_grant_type='password', + organization=organization + ) + for user, can_access, cannot_access in zip(user_list, can_access_list, cannot_access_list): + response = post( + reverse('api:o_auth2_application_token_list', kwargs={'pk': app.pk}), + {'scope': 'read'}, user, expect=201 + ) + token = AccessToken.objects.get(token=response.data['token']) + access = OAuth2TokenAccess(system_auditor) + assert access.can_read(token) is can_access + assert access.can_change(token, {}) is cannot_access + assert access.can_delete(token) is cannot_access + + def test_user_auditor_can_change( + self, post, org_member, org_admin, system_auditor, organization + ): + app = Application.objects.create( + name='test app for {}'.format(org_admin.username), user=org_admin, + client_type='confidential', authorization_grant_type='password', + organization=organization + ) + response = post( + reverse('api:o_auth2_application_token_list', kwargs={'pk': app.pk}), + {'scope': 'read'}, org_member, expect=201 + ) + token = AccessToken.objects.get(token=response.data['token']) + access = OAuth2TokenAccess(system_auditor) + assert access.can_read(token) is True + assert access.can_change(token, {}) is False + assert access.can_delete(token) is False + dual_user = system_auditor + organization.admin_role.members.add(dual_user) + access = OAuth2TokenAccess(dual_user) + assert access.can_read(token) is True + assert access.can_change(token, {}) is True + assert access.can_delete(token) is True + + + + def test_can_read_change_delete_personal_token_org_member( + self, post, admin, org_admin, org_member, alice + ): + # Tests who can read a token created by an org-member + user_list = [admin, org_admin, org_member, alice] + can_access_list = [True, False, True, False] + response = post( + reverse('api:o_auth2_personal_token_list', kwargs={'pk': org_member.pk}), + {'scope': 'read'}, org_member, expect=201 + ) + token = AccessToken.objects.get(token=response.data['token']) + for user, can_access in zip(user_list, can_access_list): + access = OAuth2TokenAccess(user) + assert access.can_read(token) is can_access + assert access.can_change(token, {}) is can_access + assert access.can_delete(token) is can_access + + + def test_can_read_personal_token_creator( + self, post, admin, org_admin, org_member, alice + ): + # Tests the token's creator can read their tokens + user_list = [admin, org_admin, org_member, alice] + can_access_list = [True, True, True, True] + + for user, can_access in zip(user_list, can_access_list): + response = post( + reverse('api:o_auth2_personal_token_list', kwargs={'pk': user.pk}), + {'scope': 'read', 'application':None}, user, expect=201 + ) + token = AccessToken.objects.get(token=response.data['token']) + access = OAuth2TokenAccess(user) + assert access.can_read(token) is can_access + assert access.can_change(token, {}) is can_access + assert access.can_delete(token) is can_access + + @pytest.mark.parametrize("user_for_access, can_access_list", [ - (0, [True, True, True, True]), - (1, [False, True, True, False]), - (2, [False, False, True, False]), - (3, [False, False, False, True]), + (0, [True, True]), + (1, [True, True]), + (2, [True, True]), + (3, [False, False]), ]) def test_can_create( - self, post, admin, org_admin, org_member, alice, user_for_access, can_access_list + self, post, admin, org_admin, org_member, alice, user_for_access, can_access_list, organization ): user_list = [admin, org_admin, org_member, alice] for user, can_access in zip(user_list, can_access_list): app = Application.objects.create( name='test app for {}'.format(user.username), user=user, - client_type='confidential', authorization_grant_type='password' + client_type='confidential', authorization_grant_type='password', organization=organization ) post( reverse('api:o_auth2_application_token_list', kwargs={'pk': app.pk}), {'scope': 'read'}, user_list[user_for_access], expect=201 if can_access else 403 ) + diff --git a/awx/main/tests/functional/test_rbac_role.py b/awx/main/tests/functional/test_rbac_role.py index 96484f9fee..abaa8a4410 100644 --- a/awx/main/tests/functional/test_rbac_role.py +++ b/awx/main/tests/functional/test_rbac_role.py @@ -4,7 +4,7 @@ from awx.main.access import ( RoleAccess, UserAccess, TeamAccess) -from awx.main.models import Role +from awx.main.models import Role, Organization @pytest.mark.django_db @@ -50,3 +50,98 @@ def test_visible_roles(admin_user, system_auditor, rando, organization, project) assert rando not in project.admin_role assert access.can_read(project.admin_role) assert project.admin_role in Role.visible_roles(rando) + + +# Permissions when adding users to org member/admin +@pytest.mark.django_db +def test_org_user_role_attach(user, organization, inventory): + ''' + Org admins must not be able to add arbitrary users to their + organization, because that would give them admin permission to that user + ''' + admin = user('admin') + nonmember = user('nonmember') + inventory.admin_role.members.add(nonmember) + + organization.admin_role.members.add(admin) + + role_access = RoleAccess(admin) + assert not role_access.can_attach(organization.member_role, nonmember, 'members', None) + assert not role_access.can_attach(organization.admin_role, nonmember, 'members', None) + + +# Singleton user editing restrictions +@pytest.mark.django_db +def test_org_superuser_role_attach(admin_user, org_admin, organization): + ''' + Ideally, you would not add superusers to roles (particularly member_role) + but it has historically been possible + this checks that the situation does not grant unexpected permissions + ''' + organization.member_role.members.add(admin_user) + + role_access = RoleAccess(org_admin) + assert not role_access.can_attach(organization.member_role, admin_user, 'members', None) + assert not role_access.can_attach(organization.admin_role, admin_user, 'members', None) + user_access = UserAccess(org_admin) + assert not user_access.can_change(admin_user, {'last_name': 'Witzel'}) + + +# Sanity check user editing permissions combined with new org roles +@pytest.mark.django_db +def test_org_object_role_not_sufficient(user, organization): + member = user('amember') + obj_admin = user('icontrolallworkflows') + + organization.member_role.members.add(member) + organization.workflow_admin_role.members.add(obj_admin) + + user_access = UserAccess(obj_admin) + assert not user_access.can_change(member, {'last_name': 'Witzel'}) + + +# Org admin user editing permission ANY to ALL change +@pytest.mark.django_db +def test_need_all_orgs_to_admin_user(user): + ''' + Old behavior - org admin to ANY organization that a user is member of + grants permission to admin that user + New behavior enforced here - org admin to ALL organizations that a + user is member of grants permission to admin that user + ''' + org1 = Organization.objects.create(name='org1') + org2 = Organization.objects.create(name='org2') + + org1_admin = user('org1-admin') + org1.admin_role.members.add(org1_admin) + + org12_member = user('org12-member') + org1.member_role.members.add(org12_member) + org2.member_role.members.add(org12_member) + + user_access = UserAccess(org1_admin) + assert not user_access.can_change(org12_member, {'last_name': 'Witzel'}) + + role_access = RoleAccess(org1_admin) + assert not role_access.can_attach(org1.admin_role, org12_member, 'members', None) + assert not role_access.can_attach(org1.member_role, org12_member, 'members', None) + + org2.admin_role.members.add(org1_admin) + assert role_access.can_attach(org1.admin_role, org12_member, 'members', None) + assert role_access.can_attach(org1.member_role, org12_member, 'members', None) + + +# Orphaned user can be added to member role, only in special cases +@pytest.mark.django_db +def test_orphaned_user_allowed(org_admin, rando, organization): + ''' + We still allow adoption of orphaned* users by assigning them to + organization member role, but only in the situation where the + org admin already posesses indirect access to all of the user's roles + *orphaned means user is not a member of any organization + ''' + role_access = RoleAccess(org_admin) + assert role_access.can_attach(organization.member_role, rando, 'members', None) + # Cannot edit the user directly without adding to org first + user_access = UserAccess(org_admin) + assert not user_access.can_change(rando, {'last_name': 'Witzel'}) diff --git a/awx/main/tests/functional/test_rbac_user.py b/awx/main/tests/functional/test_rbac_user.py index bbfe0267cd..fc2c8cec2c 100644 --- a/awx/main/tests/functional/test_rbac_user.py +++ b/awx/main/tests/functional/test_rbac_user.py @@ -61,45 +61,21 @@ def test_user_queryset(user): @pytest.mark.django_db def test_user_accessible_objects(user, organization): + ''' + We cannot directly use accessible_objects for User model because + both editing and read permissions are obligated to complex business logic + ''' admin = user('admin', False) u = user('john', False) - assert User.accessible_objects(admin, 'admin_role').count() == 1 + access = UserAccess(admin) + assert access.get_queryset().count() == 1 # can only see himself organization.member_role.members.add(u) - organization.admin_role.members.add(admin) - assert User.accessible_objects(admin, 'admin_role').count() == 2 + organization.member_role.members.add(admin) + assert access.get_queryset().count() == 2 organization.member_role.members.remove(u) - assert User.accessible_objects(admin, 'admin_role').count() == 1 - - -@pytest.mark.django_db -def test_org_user_admin(user, organization): - admin = user('orgadmin') - member = user('orgmember') - - organization.member_role.members.add(member) - assert admin not in member.admin_role - - organization.admin_role.members.add(admin) - assert admin in member.admin_role - - organization.admin_role.members.remove(admin) - assert admin not in member.admin_role - - -@pytest.mark.django_db -def test_org_user_removed(user, organization): - admin = user('orgadmin') - member = user('orgmember') - - organization.admin_role.members.add(admin) - organization.member_role.members.add(member) - - assert admin in member.admin_role - - organization.member_role.members.remove(member) - assert admin not in member.admin_role + assert access.get_queryset().count() == 1 @pytest.mark.django_db diff --git a/awx/main/tests/job_base.py b/awx/main/tests/job_base.py deleted file mode 100644 index 0d36624a79..0000000000 --- a/awx/main/tests/job_base.py +++ /dev/null @@ -1,541 +0,0 @@ -# Python -import uuid - -# AWX -from awx.main.models import * # noqa -from awx.main.tests.base import BaseTestMixin - -TEST_PLAYBOOK = '''- hosts: all - gather_facts: false - tasks: - - name: woohoo - command: test 1 = 1 -''' - - -class BaseJobTestMixin(BaseTestMixin): - - - def _create_inventory(self, name, organization, created_by, - groups_hosts_dict): - '''Helper method for creating inventory with groups and hosts.''' - inventory = organization.inventories.create( - name=name, - created_by=created_by, - ) - for group_name, host_names in groups_hosts_dict.items(): - group = inventory.groups.create( - name=group_name, - created_by=created_by, - ) - for host_name in host_names: - host = inventory.hosts.create( - name=host_name, - created_by=created_by, - ) - group.hosts.add(host) - return inventory - - def populate(self): - # Here's a little story about the AWX Bread Company, or ABC. They - # make machines that make bread - bakers, slicers, and packagers - and - # these machines are each controlled by a Linux boxes, which is in turn - # managed by AWX. - - # Sue is the super user. You don't mess with Sue or you're toast. Ha. - self.user_sue = self.make_user('sue', super_user=True) - - # There are three organizations in ABC using Ansible, since it's the - # best thing for dev ops automation since, well, sliced bread. - - # Engineering - They design and build the machines. - self.org_eng = Organization.objects.create( - name='engineering', - created_by=self.user_sue, - ) - # Support - They fix it when it's not working. - self.org_sup = Organization.objects.create( - name='support', - created_by=self.user_sue, - ) - # Operations - They implement the production lines using the machines. - self.org_ops = Organization.objects.create( - name='operations', - created_by=self.user_sue, - ) - - # Alex is Sue's IT assistant who can also administer all of the - # organizations. - self.user_alex = self.make_user('alex') - self.org_eng.admin_role.members.add(self.user_alex) - self.org_sup.admin_role.members.add(self.user_alex) - self.org_ops.admin_role.members.add(self.user_alex) - - # Bob is the head of engineering. He's an admin for engineering, but - # also a user within the operations organization (so he can see the - # results if things go wrong in production). - self.user_bob = self.make_user('bob') - self.org_eng.admin_role.members.add(self.user_bob) - self.org_ops.member_role.members.add(self.user_bob) - - # Chuck is the lead engineer. He has full reign over engineering, but - # no other organizations. - self.user_chuck = self.make_user('chuck') - self.org_eng.admin_role.members.add(self.user_chuck) - - # Doug is the other engineer working under Chuck. He can write - # playbooks and check them, but Chuck doesn't quite think he's ready to - # run them yet. Poor Doug. - self.user_doug = self.make_user('doug') - self.org_eng.member_role.members.add(self.user_doug) - - # Juan is another engineer working under Chuck. He has a little more freedom - # to run playbooks but can't create job templates - self.user_juan = self.make_user('juan') - self.org_eng.member_role.members.add(self.user_juan) - - # Hannibal is Chuck's right-hand man. Chuck usually has him create the job - # templates that the rest of the team will use - self.user_hannibal = self.make_user('hannibal') - self.org_eng.member_role.members.add(self.user_hannibal) - - # Eve is the head of support. She can also see what goes on in - # operations to help them troubleshoot problems. - self.user_eve = self.make_user('eve') - self.org_sup.admin_role.members.add(self.user_eve) - self.org_ops.member_role.members.add(self.user_eve) - - # Frank is the other support guy. - self.user_frank = self.make_user('frank') - self.org_sup.member_role.members.add(self.user_frank) - - # Greg is the head of operations. - self.user_greg = self.make_user('greg') - self.org_ops.admin_role.members.add(self.user_greg) - - # Holly is an operations engineer. - self.user_holly = self.make_user('holly') - self.org_ops.member_role.members.add(self.user_holly) - - # Iris is another operations engineer. - self.user_iris = self.make_user('iris') - self.org_ops.member_role.members.add(self.user_iris) - - # Randall and Billybob are new ops interns that ops uses to test - # their playbooks and inventory - self.user_randall = self.make_user('randall') - self.org_ops.member_role.members.add(self.user_randall) - - # He works with Randall - self.user_billybob = self.make_user('billybob') - self.org_ops.member_role.members.add(self.user_billybob) - - # Jim is the newest intern. He can login, but can't do anything quite yet - # except make everyone else fresh coffee. - self.user_jim = self.make_user('jim') - - # There are three main projects, one each for the development, test and - # production branches of the playbook repository. All three orgs can - # use the production branch, support can use the production and testing - # branches, and operations can only use the production branch. - self.proj_dev = self.make_project('dev', 'development branch', - self.user_sue, TEST_PLAYBOOK) - self.org_eng.projects.add(self.proj_dev) - self.proj_test = self.make_project('test', 'testing branch', - self.user_sue, TEST_PLAYBOOK) - #self.org_eng.projects.add(self.proj_test) # No more multi org projects - self.org_sup.projects.add(self.proj_test) - self.proj_prod = self.make_project('prod', 'production branch', - self.user_sue, TEST_PLAYBOOK) - #self.org_eng.projects.add(self.proj_prod) # No more multi org projects - #self.org_sup.projects.add(self.proj_prod) # No more multi org projects - self.org_ops.projects.add(self.proj_prod) - - # Operations also has 2 additional projects specific to the east/west - # production environments. - self.proj_prod_east = self.make_project('prod-east', - 'east production branch', - self.user_sue, TEST_PLAYBOOK) - self.org_ops.projects.add(self.proj_prod_east) - self.proj_prod_west = self.make_project('prod-west', - 'west production branch', - self.user_sue, TEST_PLAYBOOK) - self.org_ops.projects.add(self.proj_prod_west) - - # The engineering organization has a set of servers to use for - # development and testing (2 bakers, 1 slicer, 1 packager). - self.inv_eng = self._create_inventory( - name='engineering environment', - organization=self.org_eng, - created_by=self.user_sue, - groups_hosts_dict={ - 'bakers': ['eng-baker1', 'eng-baker2'], - 'slicers': ['eng-slicer1'], - 'packagers': ['eng-packager1'], - }, - ) - - # The support organization has a set of servers to use for - # testing and reproducing problems from operations (1 baker, 1 slicer, - # 1 packager). - self.inv_sup = self._create_inventory( - name='support environment', - organization=self.org_sup, - created_by=self.user_sue, - groups_hosts_dict={ - 'bakers': ['sup-baker1'], - 'slicers': ['sup-slicer1'], - 'packagers': ['sup-packager1'], - }, - ) - - # The operations organization manages multiple sets of servers for the - # east and west production facilities. - self.inv_ops_east = self._create_inventory( - name='east production environment', - organization=self.org_ops, - created_by=self.user_sue, - groups_hosts_dict={ - 'bakers': ['east-baker%d' % n for n in range(1, 4)], - 'slicers': ['east-slicer%d' % n for n in range(1, 3)], - 'packagers': ['east-packager%d' % n for n in range(1, 3)], - }, - ) - self.inv_ops_west = self._create_inventory( - name='west production environment', - organization=self.org_ops, - created_by=self.user_sue, - groups_hosts_dict={ - 'bakers': ['west-baker%d' % n for n in range(1, 6)], - 'slicers': ['west-slicer%d' % n for n in range(1, 4)], - 'packagers': ['west-packager%d' % n for n in range(1, 3)], - }, - ) - - # Operations is divided into teams to work on the east/west servers. - # Greg and Holly work on east, Greg and iris work on west. - self.team_ops_east = self.org_ops.teams.create( - name='easterners', - created_by=self.user_sue) - self.team_ops_east.member_role.children.add(self.proj_prod.admin_role) - self.team_ops_east.member_role.children.add(self.proj_prod_east.admin_role) - self.team_ops_east.member_role.members.add(self.user_greg) - self.team_ops_east.member_role.members.add(self.user_holly) - self.team_ops_west = self.org_ops.teams.create( - name='westerners', - created_by=self.user_sue) - self.team_ops_west.member_role.children.add(self.proj_prod.admin_role) - self.team_ops_west.member_role.children.add(self.proj_prod_west.admin_role) - self.team_ops_west.member_role.members.add(self.user_greg) - self.team_ops_west.member_role.members.add(self.user_iris) - - # The south team is no longer active having been folded into the east team - # FIXME: This code can be removed (probably) - # - this case has been removed as we've gotten rid of the active flag, keeping - # code around in case this has ramifications on some test failures.. if - # you find this message and all tests are passing, then feel free to remove this - # - anoek 2016-03-10 - #self.team_ops_south = self.org_ops.teams.create( - # name='southerners', - # created_by=self.user_sue, - # active=False, - #) - #self.team_ops_south.member_role.children.add(self.proj_prod.admin_role) - #self.team_ops_south.member_role.members.add(self.user_greg) - - # The north team is going to be deleted - self.team_ops_north = self.org_ops.teams.create( - name='northerners', - created_by=self.user_sue, - ) - self.team_ops_north.member_role.children.add(self.proj_prod.admin_role) - self.team_ops_north.member_role.members.add(self.user_greg) - - # The testers team are interns that can only check playbooks but can't - # run them - self.team_ops_testers = self.org_ops.teams.create( - name='testers', - created_by=self.user_sue, - ) - self.team_ops_testers.member_role.children.add(self.proj_prod.admin_role) - self.team_ops_testers.member_role.members.add(self.user_randall) - self.team_ops_testers.member_role.members.add(self.user_billybob) - - # Each user has his/her own set of credentials. - from awx.main.tests.data.ssh import (TEST_SSH_KEY_DATA, - TEST_SSH_KEY_DATA_LOCKED, - TEST_SSH_KEY_DATA_UNLOCK) - self.cred_sue = Credential.objects.create( - username='sue', - password=TEST_SSH_KEY_DATA, - created_by=self.user_sue, - ) - self.cred_sue.admin_role.members.add(self.user_sue) - - self.cred_sue_ask = Credential.objects.create( - username='sue', - password='ASK', - created_by=self.user_sue, - ) - self.cred_sue_ask.admin_role.members.add(self.user_sue) - - self.cred_sue_ask_many = Credential.objects.create( - username='sue', - password='ASK', - become_method='sudo', - become_username='root', - become_password='ASK', - ssh_key_data=TEST_SSH_KEY_DATA_LOCKED, - ssh_key_unlock='ASK', - created_by=self.user_sue, - ) - self.cred_sue_ask_many.admin_role.members.add(self.user_sue) - - self.cred_bob = Credential.objects.create( - username='bob', - password='ASK', - created_by=self.user_sue, - ) - self.cred_bob.use_role.members.add(self.user_bob) - - self.cred_chuck = Credential.objects.create( - username='chuck', - ssh_key_data=TEST_SSH_KEY_DATA, - created_by=self.user_sue, - ) - self.cred_chuck.use_role.members.add(self.user_chuck) - - self.cred_doug = Credential.objects.create( - username='doug', - password='doug doesn\'t mind his password being saved. this ' - 'is why we dont\'t let doug actually run jobs.', - created_by=self.user_sue, - ) - self.cred_doug.use_role.members.add(self.user_doug) - - self.cred_eve = Credential.objects.create( - username='eve', - password='ASK', - become_method='sudo', - become_username='root', - become_password='ASK', - created_by=self.user_sue, - ) - self.cred_eve.use_role.members.add(self.user_eve) - - self.cred_frank = Credential.objects.create( - username='frank', - password='fr@nk the t@nk', - created_by=self.user_sue, - ) - self.cred_frank.use_role.members.add(self.user_frank) - - self.cred_greg = Credential.objects.create( - username='greg', - ssh_key_data=TEST_SSH_KEY_DATA_LOCKED, - ssh_key_unlock='ASK', - created_by=self.user_sue, - ) - self.cred_greg.use_role.members.add(self.user_greg) - - self.cred_holly = Credential.objects.create( - username='holly', - password='holly rocks', - created_by=self.user_sue, - ) - self.cred_holly.use_role.members.add(self.user_holly) - - self.cred_iris = Credential.objects.create( - username='iris', - password='ASK', - created_by=self.user_sue, - ) - self.cred_iris.use_role.members.add(self.user_iris) - - # Each operations team also has shared credentials they can use. - self.cred_ops_east = Credential.objects.create( - username='east', - ssh_key_data=TEST_SSH_KEY_DATA_LOCKED, - ssh_key_unlock=TEST_SSH_KEY_DATA_UNLOCK, - created_by = self.user_sue, - ) - self.team_ops_east.member_role.children.add(self.cred_ops_east.use_role) - - self.cred_ops_west = Credential.objects.create( - username='west', - password='Heading270', - created_by = self.user_sue, - ) - self.team_ops_west.member_role.children.add(self.cred_ops_west.use_role) - - - # FIXME: This code can be removed (probably) - # - this case has been removed as we've gotten rid of the active flag, keeping - # code around in case this has ramifications on some test failures.. if - # you find this message and all tests are passing, then feel free to remove this - # - anoek 2016-03-10 - #self.cred_ops_south = self.team_ops_south.credentials.create( - # username='south', - # password='Heading180', - # created_by = self.user_sue, - #) - - self.cred_ops_north = Credential.objects.create( - username='north', - password='Heading0', - created_by = self.user_sue, - ) - self.team_ops_north.member_role.children.add(self.cred_ops_north.admin_role) - - self.cred_ops_test = Credential.objects.create( - username='testers', - password='HeadingNone', - created_by = self.user_sue, - ) - self.team_ops_testers.member_role.children.add(self.cred_ops_test.use_role) - - # Engineering has job templates to check/run the dev project onto - # their own inventory. - self.jt_eng_check = JobTemplate.objects.create( - name='eng-dev-check', - job_type='check', - inventory= self.inv_eng, - project=self.proj_dev, - playbook=self.proj_dev.playbooks[0], - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - # self.job_eng_check = self.jt_eng_check.create_job( - # created_by=self.user_sue, - # credential=self.cred_doug, - # ) - self.jt_eng_run = JobTemplate.objects.create( - name='eng-dev-run', - job_type='run', - inventory= self.inv_eng, - project=self.proj_dev, - playbook=self.proj_dev.playbooks[0], - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ask_credential_on_launch=True, - ) - # self.job_eng_run = self.jt_eng_run.create_job( - # created_by=self.user_sue, - # credential=self.cred_chuck, - # ) - - # Support has job templates to check/run the test project onto - # their own inventory. - self.jt_sup_check = JobTemplate.objects.create( - name='sup-test-check', - job_type='check', - inventory= self.inv_sup, - project=self.proj_test, - playbook=self.proj_test.playbooks[0], - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - # self.job_sup_check = self.jt_sup_check.create_job( - # created_by=self.user_sue, - # credential=self.cred_frank, - # ) - self.jt_sup_run = JobTemplate.objects.create( - name='sup-test-run', - job_type='run', - inventory= self.inv_sup, - project=self.proj_test, - playbook=self.proj_test.playbooks[0], - host_config_key=uuid.uuid4().hex, - credential=self.cred_eve, - created_by=self.user_sue, - ) - # self.job_sup_run = self.jt_sup_run.create_job( - # created_by=self.user_sue, - # ) - - # Operations has job templates to check/run the prod project onto - # both east and west inventories, by default using the team credential. - self.jt_ops_east_check = JobTemplate.objects.create( - name='ops-east-prod-check', - job_type='check', - inventory= self.inv_ops_east, - project=self.proj_prod, - playbook=self.proj_prod.playbooks[0], - credential=self.cred_ops_east, - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - # self.job_ops_east_check = self.jt_ops_east_check.create_job( - # created_by=self.user_sue, - # ) - self.jt_ops_east_run = JobTemplate.objects.create( - name='ops-east-prod-run', - job_type='run', - inventory= self.inv_ops_east, - project=self.proj_prod, - playbook=self.proj_prod.playbooks[0], - credential=self.cred_ops_east, - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - self.jt_ops_east_run_prod_east = JobTemplate.objects.create( - name='ops-east-prod-run-on-prod-east', - job_type='run', - inventory= self.inv_ops_east, - project=self.proj_prod_east, - playbook=self.proj_prod_east.playbooks[0], - credential=self.cred_ops_east, - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - # self.job_ops_east_run = self.jt_ops_east_run.create_job( - # created_by=self.user_sue, - # ) - self.jt_ops_west_check = JobTemplate.objects.create( - name='ops-west-prod-check', - job_type='check', - inventory= self.inv_ops_west, - project=self.proj_prod, - playbook=self.proj_prod.playbooks[0], - credential=self.cred_ops_west, - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - self.jt_ops_west_check_test_team = JobTemplate.objects.create( - name='ops-west-prod-check-testers', - job_type='check', - inventory= self.inv_ops_west, - project=self.proj_prod, - playbook=self.proj_prod.playbooks[0], - credential=self.cred_ops_test, - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - # self.job_ops_west_check = self.jt_ops_west_check.create_job( - # created_by=self.user_sue, - # ) - self.jt_ops_west_run = JobTemplate.objects.create( - name='ops-west-prod-run', - job_type='run', - inventory= self.inv_ops_west, - project=self.proj_prod, - playbook=self.proj_prod.playbooks[0], - credential=self.cred_ops_west, - host_config_key=uuid.uuid4().hex, - created_by=self.user_sue, - ) - # self.job_ops_west_run = self.jt_ops_west_run.create_job( - # created_by=self.user_sue, - # ) - - def setUp(self): - super(BaseJobTestMixin, self).setUp() - self.start_rabbit() - self.setup_instances() - self.populate() - self.start_queue() - - def tearDown(self): - super(BaseJobTestMixin, self).tearDown() - self.stop_rabbit() - self.terminate_queue() diff --git a/awx/main/tests/unit/api/serializers/test_activity_stream_serializer.py b/awx/main/tests/unit/api/serializers/test_activity_stream_serializer.py new file mode 100644 index 0000000000..50849b31c5 --- /dev/null +++ b/awx/main/tests/unit/api/serializers/test_activity_stream_serializer.py @@ -0,0 +1,31 @@ +from awx.api.serializers import ActivityStreamSerializer +from awx.main.registrar import activity_stream_registrar +from awx.main.models import ActivityStream + +from awx.conf.models import Setting + + +def test_activity_stream_related(): + ''' + If this test failed with content in `missing_models`, that means that a + model has been connected to the activity stream, but the model has not + been added to the activity stream serializer. + + How to fix this: + Ideally, all models should be in awx.api.serializers.SUMMARIZABLE_FK_FIELDS + + If, for whatever reason, the missing model should not generally be + summarized from related resources, then a special case can be carved out in + ActivityStreamSerializer._local_summarizable_fk_fields + ''' + serializer_related = set( + ActivityStream._meta.get_field(field_name).related_model for field_name, stuff in + ActivityStreamSerializer()._local_summarizable_fk_fields + if hasattr(ActivityStream, field_name) + ) + + models = set(activity_stream_registrar.models) + models.remove(Setting) + + missing_models = models - serializer_related + assert not missing_models diff --git a/awx/main/tests/unit/api/serializers/test_token_serializer.py b/awx/main/tests/unit/api/serializers/test_token_serializer.py new file mode 100644 index 0000000000..5ead166664 --- /dev/null +++ b/awx/main/tests/unit/api/serializers/test_token_serializer.py @@ -0,0 +1,14 @@ +import pytest + +from awx.api.serializers import OAuth2TokenSerializer + + +@pytest.mark.parametrize('scope, expect', [ + ('', False), + ('read', True), + ('read read', False), + ('write read', True), + ('read rainbow', False) +]) +def test_invalid_scopes(scope, expect): + assert OAuth2TokenSerializer()._is_valid_scope(scope) is expect diff --git a/awx/main/tests/unit/api/test_roles.py b/awx/main/tests/unit/api/test_roles.py index 5cb49a92b0..a51dbb9f58 100644 --- a/awx/main/tests/unit/api/test_roles.py +++ b/awx/main/tests/unit/api/test_roles.py @@ -1,7 +1,4 @@ import mock -from mock import PropertyMock - -import pytest from rest_framework.test import APIRequestFactory from rest_framework.test import force_authenticate @@ -9,8 +6,6 @@ from rest_framework.test import force_authenticate from django.contrib.contenttypes.models import ContentType from awx.api.views import ( - RoleUsersList, - UserRolesList, TeamRolesList, ) @@ -20,69 +15,6 @@ from awx.main.models import ( ) -@pytest.mark.parametrize("pk, err", [ - (111, "not change the membership"), - (1, "may not perform"), -]) -def test_user_roles_list_user_admin_role(pk, err): - with mock.patch('awx.api.views.get_object_or_400') as role_get, \ - mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get: - - role_mock = mock.MagicMock(spec=Role, id=1, pk=1) - content_type_mock = mock.MagicMock(spec=ContentType) - role_mock.content_type = content_type_mock - role_get.return_value = role_mock - ct_get.return_value = content_type_mock - - with mock.patch('awx.api.views.User.admin_role', new_callable=PropertyMock, return_value=role_mock): - factory = APIRequestFactory() - view = UserRolesList.as_view() - - user = User(username="root", is_superuser=True, pk=1, id=1) - - request = factory.post("/user/1/roles", {'id':pk}, format="json") - force_authenticate(request, user) - - response = view(request, pk=user.pk) - response.render() - - assert response.status_code == 403 - assert err in response.content - - -@pytest.mark.parametrize("admin_role, err", [ - (True, "may not perform"), - (False, "not change the membership"), -]) -def test_role_users_list_other_user_admin_role(admin_role, err): - with mock.patch('awx.api.views.RoleUsersList.get_parent_object') as role_get, \ - mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get: - - role_mock = mock.MagicMock(spec=Role, id=1) - content_type_mock = mock.MagicMock(spec=ContentType) - role_mock.content_type = content_type_mock - role_get.return_value = role_mock - ct_get.return_value = content_type_mock - - user_admin_role = role_mock if admin_role else None - with mock.patch('awx.api.views.User.admin_role', new_callable=PropertyMock, return_value=user_admin_role): - factory = APIRequestFactory() - view = RoleUsersList.as_view() - - user = User(username="root", is_superuser=True, pk=1, id=1) - queried_user = User(username="maynard") - - request = factory.post("/role/1/users", {'id':1}, format="json") - force_authenticate(request, user) - - with mock.patch('awx.api.views.get_object_or_400', return_value=queried_user): - response = view(request) - response.render() - - assert response.status_code == 403 - assert err in response.content - - def test_team_roles_list_post_org_roles(): with mock.patch('awx.api.views.get_object_or_400') as role_get, \ mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get: diff --git a/awx/main/tests/unit/models/test_credential.py b/awx/main/tests/unit/models/test_credential.py new file mode 100644 index 0000000000..f71d7fa0ae --- /dev/null +++ b/awx/main/tests/unit/models/test_credential.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +from awx.main.models import Credential, CredentialType + + +def test_unique_hash_with_unicode(): + ct = CredentialType(name=u'Väult', kind='vault') + cred = Credential( + id=4, + name=u'Iñtërnâtiônàlizætiøn', + credential_type=ct, + inputs={ + u'vault_id': u'🐉🐉🐉' + }, + credential_type_id=42 + ) + assert cred.unique_hash(display=True) == u'Väult (id=🐉🐉🐉)' diff --git a/awx/main/tests/unit/models/test_survey_models.py b/awx/main/tests/unit/models/test_survey_models.py index 3b7cf0d7e6..6ce19d2060 100644 --- a/awx/main/tests/unit/models/test_survey_models.py +++ b/awx/main/tests/unit/models/test_survey_models.py @@ -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' diff --git a/awx/main/tests/unit/test_fields.py b/awx/main/tests/unit/test_fields.py new file mode 100644 index 0000000000..bec0c4de2f --- /dev/null +++ b/awx/main/tests/unit/test_fields.py @@ -0,0 +1,176 @@ +import pytest + +from django.core.exceptions import ValidationError +from rest_framework.serializers import ValidationError as DRFValidationError + +from awx.main.models import Credential, CredentialType, BaseModel +from awx.main.fields import JSONSchemaField + + +@pytest.mark.parametrize('schema, given, message', [ + ( + { # immitates what the CredentialType injectors field is + "additionalProperties": False, + "type": "object", + "properties": { + "extra_vars": { + "additionalProperties": False, + "type": "object" + } + } + }, + {'extra_vars': ['duck', 'horse']}, + "list provided in relative path ['extra_vars'], expected dict" + ), + ( + { # immitates what the CredentialType injectors field is + "additionalProperties": False, + "type": "object", + }, + ['duck', 'horse'], + "list provided, expected dict" + ), +]) +def test_custom_error_messages(schema, given, message): + instance = BaseModel() + + class MockFieldSubclass(JSONSchemaField): + def schema(self, model_instance): + return schema + + field = MockFieldSubclass() + + with pytest.raises(ValidationError) as exc: + field.validate(given, instance) + + assert message == exc.value.error_list[0].message + + +@pytest.mark.parametrize('input_, valid', [ + ({}, True), + ({'fields': []}, True), + ({'fields': {}}, False), + ({'fields': 123}, False), + ({'fields': [{'id': 'username', 'label': 'Username', 'foo': 'bar'}]}, False), + ({'fields': [{'id': 'username', 'label': 'Username'}]}, True), + ({'fields': [{'id': 'username', 'label': 'Username', 'type': 'string'}]}, True), + ({'fields': [{'id': 'username', 'label': 'Username', 'help_text': 1}]}, False), + ({'fields': [{'id': 'username', 'label': 'Username', 'help_text': 'Help Text'}]}, True), # noqa + ({'fields': [{'id': 'username', 'label': 'Username'}, {'id': 'username', 'label': 'Username 2'}]}, False), # noqa + ({'fields': [{'id': '$invalid$', 'label': 'Invalid', 'type': 'string'}]}, False), # noqa + ({'fields': [{'id': 'password', 'label': 'Password', 'type': 'invalid-type'}]}, False), + ({'fields': [{'id': 'ssh_key', 'label': 'SSH Key', 'type': 'string', 'format': 'ssh_private_key'}]}, True), # noqa + ({'fields': [{'id': 'flag', 'label': 'Some Flag', 'type': 'boolean'}]}, True), + ({'fields': [{'id': 'flag', 'label': 'Some Flag', 'type': 'boolean', 'choices': ['a', 'b']}]}, False), + ({'fields': [{'id': 'flag', 'label': 'Some Flag', 'type': 'boolean', 'secret': True}]}, False), + ({'fields': [{'id': 'certificate', 'label': 'Cert', 'multiline': True}]}, True), + ({'fields': [{'id': 'certificate', 'label': 'Cert', 'multiline': True, 'type': 'boolean'}]}, False), # noqa + ({'fields': [{'id': 'certificate', 'label': 'Cert', 'multiline': 'bad'}]}, False), # noqa + ({'fields': [{'id': 'token', 'label': 'Token', 'secret': True}]}, True), + ({'fields': [{'id': 'token', 'label': 'Token', 'secret': 'bad'}]}, False), + ({'fields': [{'id': 'token', 'label': 'Token', 'ask_at_runtime': True}]}, True), + ({'fields': [{'id': 'token', 'label': 'Token', 'ask_at_runtime': 'bad'}]}, False), # noqa + ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': 'not-a-list'}]}, False), # noqa + ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': []}]}, False), + ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': ['su', 'sudo']}]}, True), # noqa + ({'fields': [{'id': 'become_method', 'label': 'Become', 'choices': ['dup', 'dup']}]}, False), # noqa + ({'fields': [{'id': 'tower', 'label': 'Reserved!', }]}, False), # noqa +]) +def test_cred_type_input_schema_validity(input_, valid): + type_ = CredentialType( + kind='cloud', + name='SomeCloud', + managed_by_tower=True, + inputs=input_ + ) + field = CredentialType._meta.get_field('inputs') + if valid is False: + with pytest.raises(ValidationError): + field.clean(input_, type_) + else: + field.clean(input_, type_) + + +@pytest.mark.parametrize('injectors, valid', [ + ({}, True), + ({'invalid-injector': {}}, False), + ({'file': 123}, False), + ({'file': {}}, True), + ({'file': {'template': '{{username}}'}}, True), + ({'file': {'template.username': '{{username}}'}}, True), + ({'file': {'template.username': '{{username}}', 'template.password': '{{pass}}'}}, True), + ({'file': {'template': '{{username}}', 'template.password': '{{pass}}'}}, False), + ({'file': {'foo': 'bar'}}, False), + ({'env': 123}, False), + ({'env': {}}, True), + ({'env': {'AWX_SECRET': '{{awx_secret}}'}}, True), + ({'env': {'AWX_SECRET_99': '{{awx_secret}}'}}, True), + ({'env': {'99': '{{awx_secret}}'}}, False), + ({'env': {'AWX_SECRET=': '{{awx_secret}}'}}, False), + ({'extra_vars': 123}, False), + ({'extra_vars': {}}, True), + ({'extra_vars': {'hostname': '{{host}}'}}, True), + ({'extra_vars': {'hostname_99': '{{host}}'}}, True), + ({'extra_vars': {'99': '{{host}}'}}, False), + ({'extra_vars': {'99=': '{{host}}'}}, False), +]) +def test_cred_type_injectors_schema(injectors, valid): + type_ = CredentialType( + kind='cloud', + name='SomeCloud', + managed_by_tower=True, + inputs={ + 'fields': [ + {'id': 'username', 'type': 'string', 'label': '_'}, + {'id': 'pass', 'type': 'string', 'label': '_'}, + {'id': 'awx_secret', 'type': 'string', 'label': '_'}, + {'id': 'host', 'type': 'string', 'label': '_'}, + ] + }, + injectors=injectors + ) + field = CredentialType._meta.get_field('injectors') + if valid is False: + with pytest.raises(ValidationError): + field.clean(injectors, type_) + else: + field.clean(injectors, type_) + + +@pytest.mark.parametrize('inputs', [ + ['must-be-a-dict'], + {'user': 'wrong-key'}, + {'username': 1}, + {'username': 1.5}, + {'username': ['a', 'b', 'c']}, + {'username': {'a': 'b'}}, + {'flag': 1}, + {'flag': 1.5}, + {'flag': ['a', 'b', 'c']}, + {'flag': {'a': 'b'}}, + {'flag': 'some-string'}, +]) +def test_credential_creation_validation_failure(inputs): + type_ = CredentialType( + kind='cloud', + name='SomeCloud', + managed_by_tower=True, + inputs={ + 'fields': [{ + 'id': 'username', + 'label': 'Username for SomeCloud', + 'type': 'string' + },{ + 'id': 'flag', + 'label': 'Some Boolean Flag', + 'type': 'boolean' + }] + } + ) + cred = Credential(credential_type=type_, name="Bob's Credential", + inputs=inputs) + field = cred._meta.get_field('inputs') + + with pytest.raises(Exception) as e: + field.validate(inputs, cred) + assert e.type in (ValidationError, DRFValidationError) diff --git a/awx/main/tests/unit/test_redact.py b/awx/main/tests/unit/test_redact.py index 931ef72ebc..fa59ca43d7 100644 --- a/awx/main/tests/unit/test_redact.py +++ b/awx/main/tests/unit/test_redact.py @@ -1,4 +1,5 @@ import textwrap +import pytest # AWX from awx.main.redact import UriCleaner @@ -78,60 +79,76 @@ TEST_CLEARTEXT.append({ }) +@pytest.mark.parametrize('username, password, not_uri, expected', [ + ('', '', 'www.famfamfam.com](http://www.famfamfam.com/fijdlfd', 'www.famfamfam.com](http://www.famfamfam.com/fijdlfd'), + ('', '', 'https://www.famfamfam.com](http://www.famfamfam.com/fijdlfd', '$encrypted$'), + ('root', 'gigity', 'https://root@gigity@www.famfamfam.com](http://www.famfamfam.com/fijdlfd', '$encrypted$'), + ('root', 'gigity@', 'https://root:gigity@@@www.famfamfam.com](http://www.famfamfam.com/fijdlfd', '$encrypted$'), +]) # should redact sensitive usernames and passwords -def test_uri_scm_simple_redacted(): - for uri in TEST_URIS: - redacted_str = UriCleaner.remove_sensitive(str(uri)) - if uri.username: - assert uri.username not in redacted_str - if uri.password: - assert uri.username not in redacted_str +def test_non_uri_redact(username, password, not_uri, expected): + redacted_str = UriCleaner.remove_sensitive(not_uri) + if username: + assert username not in redacted_str + if password: + assert password not in redacted_str + + assert redacted_str == expected + + +def test_multiple_non_uri_redact(): + non_uri = 'https://www.famfamfam.com](http://www.famfamfam.com/fijdlfd hi ' + non_uri += 'https://www.famfamfam.com](http://www.famfamfam.com/fijdlfd world ' + non_uri += 'https://www.famfamfam.com](http://www.famfamfam.com/fijdlfd foo ' + non_uri += 'https://foo:bar@giggity.com bar' + redacted_str = UriCleaner.remove_sensitive(non_uri) + assert redacted_str == '$encrypted$ hi $encrypted$ world $encrypted$ foo https://$encrypted$:$encrypted$@giggity.com bar' # should replace secret data with safe string, UriCleaner.REPLACE_STR -def test_uri_scm_simple_replaced(): - for uri in TEST_URIS: - redacted_str = UriCleaner.remove_sensitive(str(uri)) - assert redacted_str.count(UriCleaner.REPLACE_STR) == uri.get_secret_count() +@pytest.mark.parametrize('uri', TEST_URIS) +def test_uri_scm_simple_replaced(uri): + redacted_str = UriCleaner.remove_sensitive(str(uri)) + assert redacted_str.count(UriCleaner.REPLACE_STR) == uri.get_secret_count() # should redact multiple uris in text -def test_uri_scm_multiple(): +@pytest.mark.parametrize('uri', TEST_URIS) +def test_uri_scm_multiple(uri): cleartext = '' - for uri in TEST_URIS: - cleartext += str(uri) + ' ' - for uri in TEST_URIS: - cleartext += str(uri) + '\n' + cleartext += str(uri) + ' ' + cleartext += str(uri) + '\n' redacted_str = UriCleaner.remove_sensitive(str(uri)) if uri.username: assert uri.username not in redacted_str if uri.password: - assert uri.username not in redacted_str + assert uri.password not in redacted_str # should replace multiple secret data with safe string -def test_uri_scm_multiple_replaced(): +@pytest.mark.parametrize('uri', TEST_URIS) +def test_uri_scm_multiple_replaced(uri): cleartext = '' find_count = 0 - for uri in TEST_URIS: - cleartext += str(uri) + ' ' - find_count += uri.get_secret_count() - for uri in TEST_URIS: - cleartext += str(uri) + '\n' - find_count += uri.get_secret_count() + cleartext += str(uri) + ' ' + find_count += uri.get_secret_count() + + cleartext += str(uri) + '\n' + find_count += uri.get_secret_count() redacted_str = UriCleaner.remove_sensitive(cleartext) assert redacted_str.count(UriCleaner.REPLACE_STR) == find_count # should redact and replace multiple secret data within a complex cleartext blob -def test_uri_scm_cleartext_redact_and_replace(): - for test_data in TEST_CLEARTEXT: - uri = test_data['uri'] - redacted_str = UriCleaner.remove_sensitive(test_data['text']) - assert uri.username not in redacted_str - assert uri.password not in redacted_str - # Ensure the host didn't get redacted - assert redacted_str.count(uri.host) == test_data['host_occurrences'] +@pytest.mark.parametrize('test_data', TEST_CLEARTEXT) +def test_uri_scm_cleartext_redact_and_replace(test_data): + uri = test_data['uri'] + redacted_str = UriCleaner.remove_sensitive(test_data['text']) + assert uri.username not in redacted_str + assert uri.password not in redacted_str + # Ensure the host didn't get redacted + assert redacted_str.count(uri.host) == test_data['host_occurrences'] + diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 6def112522..39a4d52de4 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- + from contextlib import contextmanager from datetime import datetime from functools import partial @@ -12,7 +14,9 @@ from backports.tempfile import TemporaryDirectory import fcntl import mock import pytest +import six import yaml + from django.conf import settings @@ -24,6 +28,7 @@ from awx.main.models import ( InventorySource, InventoryUpdate, Job, + JobTemplate, Notification, Project, ProjectUpdate, @@ -36,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 @@ -187,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 @@ -218,7 +223,7 @@ class TestJobExecution: self.run_pexpect.return_value = ['successful', 0] self.patches = [ - mock.patch.object(CallbackQueueDispatcher, 'dispatch', lambda obj: None), + mock.patch.object(CallbackQueueDispatcher, 'dispatch', lambda self, obj: None), mock.patch.object(Project, 'get_project_path', lambda *a, **kw: self.project_path), # don't emit websocket statuses; they use the DB and complicate testing mock.patch.object(UnifiedJob, 'websocket_emit_status', mock.Mock()), @@ -267,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 @@ -293,8 +299,142 @@ 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): + self.task.build_private_data_files = mock.Mock(side_effect=IOError()) + with pytest.raises(Exception): + self.task.run(self.pk) + update_model_call = self.task.update_model.call_args[1] + assert 'IOError' in update_model_call['result_traceback'] + assert update_model_call['status'] == 'error' + assert update_model_call['emitted_events'] == 0 + def test_cancel_flag(self): self.instance.cancel_flag = True with pytest.raises(Exception): @@ -460,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') @@ -571,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( @@ -1158,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 @@ -1309,6 +1484,33 @@ class TestJobCredentials(TestJobExecution): self.run_pexpect.side_effect = run_pexpect_side_effect self.task.run(self.pk) + def test_custom_environment_injectors_with_unicode_content(self): + value = six.u('Iñtërnâtiônàlizætiøn') + some_cloud = CredentialType( + kind='cloud', + name='SomeCloud', + managed_by_tower=False, + inputs={'fields': []}, + injectors={ + 'file': {'template': value}, + 'env': {'MY_CLOUD_INI_FILE': '{{tower.filename}}'} + } + ) + credential = Credential( + pk=1, + credential_type=some_cloud, + ) + self.instance.credentials.add(credential) + self.task.run(self.pk) + + def run_pexpect_side_effect(*args, **kwargs): + args, cwd, env, stdout = args + assert open(env['MY_CLOUD_INI_FILE'], 'rb').read() == value.encode('utf-8') + return ['successful', 0] + + self.run_pexpect.side_effect = run_pexpect_side_effect + self.task.run(self.pk) + def test_custom_environment_injectors_with_files(self): some_cloud = CredentialType( kind='cloud', diff --git a/awx/main/tests/unit/utils/test_common.py b/awx/main/tests/unit/utils/test_common.py index 0f50bac6b1..fa4a038037 100644 --- a/awx/main/tests/unit/utils/test_common.py +++ b/awx/main/tests/unit/utils/test_common.py @@ -44,6 +44,16 @@ def test_parse_yaml_or_json(input_, output): assert common.parse_yaml_or_json(input_) == output +def test_recursive_vars_not_allowed(): + rdict = {} + rdict['a'] = rdict + # YAML dumper will use a tag to give recursive data + data = yaml.dump(rdict, default_flow_style=False) + with pytest.raises(ParseError) as exc: + common.parse_yaml_or_json(data, silent_failure=False) + assert 'Circular reference detected' in str(exc) + + class TestParserExceptions: @staticmethod diff --git a/awx/main/tests/unit/utils/test_event_filter.py b/awx/main/tests/unit/utils/test_event_filter.py index 85ecc609d0..fb8f4fa144 100644 --- a/awx/main/tests/unit/utils/test_event_filter.py +++ b/awx/main/tests/unit/utils/test_event_filter.py @@ -5,7 +5,7 @@ from StringIO import StringIO from six.moves import xrange -from awx.main.utils import OutputEventFilter +from awx.main.utils import OutputEventFilter, OutputVerboseFilter MAX_WIDTH = 78 EXAMPLE_UUID = '890773f5-fe6d-4091-8faf-bdc8021d65dd' @@ -145,3 +145,55 @@ def test_large_stdout_blob(): f = OutputEventFilter(_callback) for x in range(1024 * 10): f.write('x' * 1024) + + +def test_verbose_line_buffering(): + events = [] + + def _callback(event_data): + events.append(event_data) + + f = OutputVerboseFilter(_callback) + f.write('one two\r\n\r\n') + + assert len(events) == 2 + assert events[0]['start_line'] == 0 + assert events[0]['end_line'] == 1 + assert events[0]['stdout'] == 'one two' + + assert events[1]['start_line'] == 1 + assert events[1]['end_line'] == 2 + assert events[1]['stdout'] == '' + + f.write('three') + assert len(events) == 2 + f.write('\r\nfou') + + # three is not pushed to buffer until its line completes + assert len(events) == 3 + assert events[2]['start_line'] == 2 + assert events[2]['end_line'] == 3 + assert events[2]['stdout'] == 'three' + + f.write('r\r') + f.write('\nfi') + + assert events[3]['start_line'] == 3 + assert events[3]['end_line'] == 4 + assert events[3]['stdout'] == 'four' + + f.write('ve') + f.write('\r\n') + + assert len(events) == 5 + assert events[4]['start_line'] == 4 + assert events[4]['end_line'] == 5 + assert events[4]['stdout'] == 'five' + + f.close() + + from pprint import pprint + pprint(events) + assert len(events) == 6 + + assert events[5]['event'] == 'EOF' diff --git a/awx/main/tests/unit/utils/test_filters.py b/awx/main/tests/unit/utils/test_filters.py index 964466cb73..c0b38d294c 100644 --- a/awx/main/tests/unit/utils/test_filters.py +++ b/awx/main/tests/unit/utils/test_filters.py @@ -39,6 +39,7 @@ class TestSmartFilterQueryFromString(): ('a__b__c=3.14', Q(**{u"a__b__c": 3.14})), ('a__b__c=true', Q(**{u"a__b__c": True})), ('a__b__c=false', Q(**{u"a__b__c": False})), + ('a__b__c=null', Q(**{u"a__b__c": None})), ('ansible_facts__a="true"', Q(**{u"ansible_facts__contains": {u"a": u"true"}})), #('"a__b\"__c"="true"', Q(**{u"a__b\"__c": "true"})), #('a__b\"__c="true"', Q(**{u"a__b\"__c": "true"})), @@ -114,7 +115,7 @@ class TestSmartFilterQueryFromString(): assert six.text_type(q) == six.text_type(q_expected) @pytest.mark.parametrize("filter_string,q_expected", [ - ('ansible_facts__a=null', Q(**{u"ansible_facts__contains": {u"a": u"null"}})), + ('ansible_facts__a=null', Q(**{u"ansible_facts__contains": {u"a": None}})), ('ansible_facts__c="null"', Q(**{u"ansible_facts__contains": {u"c": u"\"null\""}})), ]) def test_contains_query_generated_null(self, mock_get_host_model, filter_string, q_expected): @@ -130,7 +131,10 @@ class TestSmartFilterQueryFromString(): Q(**{u"group__name__contains": u"foo"}) | Q(**{u"group__description__contains": u"foo"}))), ('search=foo or ansible_facts__a=null', Q(Q(**{u"name__contains": u"foo"}) | Q(**{u"description__contains": u"foo"})) | - Q(**{u"ansible_facts__contains": {u"a": u"null"}})), + Q(**{u"ansible_facts__contains": {u"a": None}})), + ('search=foo or ansible_facts__a="null"', + Q(Q(**{u"name__contains": u"foo"}) | Q(**{u"description__contains": u"foo"})) | + Q(**{u"ansible_facts__contains": {u"a": u"\"null\""}})), ]) def test_search_related_fields(self, mock_get_host_model, filter_string, q_expected): q = SmartFilter.query_from_string(filter_string) diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py index 95e7aa260b..edd44b7958 100644 --- a/awx/main/tests/unit/utils/test_ha.py +++ b/awx/main/tests/unit/utils/test_ha.py @@ -60,7 +60,6 @@ class TestAddRemoveCeleryWorkerQueues(): static_queues, _worker_queues, groups, hostname, added_expected, removed_expected): - added_expected.append('tower_instance_router') instance = instance_generator(groups=groups, hostname=hostname) worker_queues = worker_queues_generator(_worker_queues) with mock.patch('awx.main.utils.ha.settings.AWX_CELERY_QUEUES_STATIC', static_queues): diff --git a/awx/main/tests/unit/utils/test_safe_yaml.py b/awx/main/tests/unit/utils/test_safe_yaml.py new file mode 100644 index 0000000000..8e8dd933aa --- /dev/null +++ b/awx/main/tests/unit/utils/test_safe_yaml.py @@ -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 diff --git a/awx/main/utils/common.py b/awx/main/utils/common.py index ba3413a133..a024e7d649 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -48,7 +48,7 @@ __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', 'copy_m2m_relationships', 'prefetch_page_capabilities', 'to_python_boolean', 'ignore_inventory_computed_fields', 'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd', 'NoDefaultProvided', - 'get_current_apps', 'set_current_apps', 'OutputEventFilter', + 'get_current_apps', 'set_current_apps', 'OutputEventFilter', 'OutputVerboseFilter', 'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', 'get_cpu_capacity', 'get_mem_capacity', 'wrap_args_with_proot', 'build_proot_temp_dir', 'check_proot_installed', 'model_to_dict', 'model_instance_diff', 'timestamp_apiformat', 'parse_yaml_or_json', 'RequireDebugTrueOrTest', @@ -350,11 +350,14 @@ def get_allowed_fields(obj, serializer_mapping): allowed_fields = [x for x in serializer_actual.fields if not serializer_actual.fields[x].read_only] + ['id'] else: allowed_fields = [x.name for x in obj._meta.fields] - if obj._meta.model_name == 'user': - field_blacklist = ['last_login'] - allowed_fields = [f for f in allowed_fields if f not in field_blacklist] - if obj._meta.model_name == 'oauth2application': - field_blacklist = ['client_secret'] + + ACTIVITY_STREAM_FIELD_EXCLUSIONS = { + 'user': ['last_login'], + 'oauth2accesstoken': ['last_used'], + 'oauth2application': ['client_secret'] + } + field_blacklist = ACTIVITY_STREAM_FIELD_EXCLUSIONS.get(obj._meta.model_name, []) + if field_blacklist: allowed_fields = [f for f in allowed_fields if f not in field_blacklist] return allowed_fields @@ -380,7 +383,7 @@ def _convert_model_field_for_display(obj, field_name, password_fields=None): field_val = json.dumps(field_val, ensure_ascii=False) except Exception: pass - if type(field_val) not in (bool, int, type(None)): + if type(field_val) not in (bool, int, type(None), long): field_val = smart_str(field_val) return field_val @@ -413,10 +416,8 @@ def model_instance_diff(old, new, serializer_mapping=None): _convert_model_field_for_display(old, field, password_fields=old_password_fields), _convert_model_field_for_display(new, field, password_fields=new_password_fields), ) - if len(diff) == 0: diff = None - return diff @@ -435,7 +436,6 @@ def model_to_dict(obj, serializer_mapping=None): if field.name not in allowed_fields: continue attr_d[field.name] = _convert_model_field_for_display(obj, field.name, password_fields=password_fields) - return attr_d @@ -630,8 +630,16 @@ def parse_yaml_or_json(vars_str, silent_failure=True): vars_dict = yaml.safe_load(vars_str) # Can be None if '---' if vars_dict is None: - return {} + vars_dict = {} validate_vars_type(vars_dict) + if not silent_failure: + # is valid YAML, check that it is compatible with JSON + try: + json.dumps(vars_dict) + except (ValueError, TypeError, AssertionError) as json_err2: + raise ParseError(_( + 'Variables not compatible with JSON standard (error: {json_error})').format( + json_error=str(json_err2))) except (yaml.YAMLError, TypeError, AttributeError, AssertionError) as yaml_err: if silent_failure: return {} @@ -1009,6 +1017,32 @@ class OutputEventFilter(object): self._current_event_data = None +class OutputVerboseFilter(OutputEventFilter): + ''' + File-like object that dispatches stdout data. + Does not search for encoded job event data. + Use for unified job types that do not encode job event data. + ''' + def write(self, data): + self._buffer.write(data) + + # if the current chunk contains a line break + if data and '\n' in data: + # emit events for all complete lines we know about + lines = self._buffer.getvalue().splitlines(True) # keep ends + remainder = None + # if last line is not a complete line, then exclude it + if '\n' not in lines[-1]: + remainder = lines.pop() + # emit all complete lines + for line in lines: + self._emit_event(line) + self._buffer = StringIO() + # put final partial line back on buffer + if remainder: + self._buffer.write(remainder) + + def is_ansible_variable(key): return key.startswith('ansible_') diff --git a/awx/main/utils/filters.py b/awx/main/utils/filters.py index a1d316ba72..81f91a0b0a 100644 --- a/awx/main/utils/filters.py +++ b/awx/main/utils/filters.py @@ -19,6 +19,8 @@ __all__ = ['SmartFilter'] def string_to_type(t): + if t == u'null': + return None if t == u'true': return True elif t == u'false': diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py index 9aef118fe7..93a7f8dd24 100644 --- a/awx/main/utils/ha.py +++ b/awx/main/utils/ha.py @@ -3,9 +3,6 @@ # Copyright (c) 2017 Ansible Tower by Red Hat # All Rights Reserved. -# Python -import six - # Django from django.conf import settings @@ -16,24 +13,26 @@ from awx.main.models import Instance def _add_remove_celery_worker_queues(app, controlled_instances, worker_queues, worker_name): removed_queues = [] added_queues = [] - ig_names = set([six.text_type('tower_instance_router')]) + ig_names = set() hostnames = set([instance.hostname for instance in controlled_instances]) for instance in controlled_instances: ig_names.update(instance.rampart_groups.values_list('name', flat=True)) worker_queue_names = set([q['name'] for q in worker_queues]) + all_queue_names = ig_names | hostnames | set(settings.AWX_CELERY_QUEUES_STATIC) + # Remove queues that aren't in the instance group for queue in worker_queues: if queue['name'] in settings.AWX_CELERY_QUEUES_STATIC or \ - queue['alias'] in settings.AWX_CELERY_QUEUES_STATIC: + queue['alias'] in settings.AWX_CELERY_BCAST_QUEUES_STATIC: continue - if queue['name'] not in ig_names | hostnames or not instance.enabled: + if queue['name'] not in all_queue_names or not instance.enabled: app.control.cancel_consumer(queue['name'].encode("utf8"), reply=True, destination=[worker_name]) removed_queues.append(queue['name'].encode("utf8")) # Add queues for instance and instance groups - for queue_name in ig_names | hostnames: + for queue_name in all_queue_names: if queue_name not in worker_queue_names: app.control.add_consumer(queue_name.encode("utf8"), reply=True, destination=[worker_name]) added_queues.append(queue_name.encode("utf8")) @@ -76,6 +75,5 @@ def register_celery_worker_queues(app, celery_worker_name): celery_worker_queues = celery_host_queues[celery_worker_name] if celery_host_queues else [] (added_queues, removed_queues) = _add_remove_celery_worker_queues(app, controlled_instances, celery_worker_queues, celery_worker_name) - return (controlled_instances, removed_queues, added_queues) diff --git a/awx/main/utils/polymorphic.py b/awx/main/utils/polymorphic.py index 4eabba213e..f8ed1bc160 100644 --- a/awx/main/utils/polymorphic.py +++ b/awx/main/utils/polymorphic.py @@ -1,5 +1,6 @@ from django.contrib.contenttypes.models import ContentType +from django.db import models def build_polymorphic_ctypes_map(cls): @@ -10,3 +11,7 @@ def build_polymorphic_ctypes_map(cls): if ct_model_class and issubclass(ct_model_class, cls): mapping[ct.id] = ct_model_class._camel_to_underscore(ct_model_class.__name__) return mapping + + +def SET_NULL(collector, field, sub_objs, using): + return models.SET_NULL(collector, field, sub_objs.non_polymorphic(), using) diff --git a/awx/main/utils/safe_yaml.py b/awx/main/utils/safe_yaml.py new file mode 100644 index 0000000000..28c4dc4694 --- /dev/null +++ b/awx/main/utils/safe_yaml.py @@ -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 diff --git a/awx/network_ui/consumers.py b/awx/network_ui/consumers.py index a67fe0a2e5..36cbb24803 100644 --- a/awx/network_ui/consumers.py +++ b/awx/network_ui/consumers.py @@ -80,7 +80,7 @@ class NetworkingEvents(object): type='device_type', id='cid', host_id='host_id'), device) - logger.info("Device %s", device) + logger.info("Device created %s", device) d, _ = Device.objects.get_or_create(topology_id=topology_id, cid=device['cid'], defaults=device) d.x = device['x'] d.y = device['y'] @@ -92,6 +92,7 @@ class NetworkingEvents(object): .update(device_id_seq=device['cid'])) def onDeviceDestroy(self, device, topology_id, client_id): + logger.info("Device removed %s", device) Device.objects.filter(topology_id=topology_id, cid=device['id']).delete() def onDeviceMove(self, device, topology_id, client_id): @@ -101,6 +102,7 @@ class NetworkingEvents(object): Device.objects.filter(topology_id=topology_id, cid=device['id']).update(host_id=device['host_id']) def onDeviceLabelEdit(self, device, topology_id, client_id): + logger.debug("Device label edited %s", device) Device.objects.filter(topology_id=topology_id, cid=device['id']).update(name=device['name']) def onInterfaceLabelEdit(self, interface, topology_id, client_id): @@ -111,6 +113,7 @@ class NetworkingEvents(object): .update(name=interface['name'])) def onLinkLabelEdit(self, link, topology_id, client_id): + logger.debug("Link label edited %s", link) Link.objects.filter(from_device__topology_id=topology_id, cid=link['id']).update(name=link['name']) def onInterfaceCreate(self, interface, topology_id, client_id): @@ -125,6 +128,7 @@ class NetworkingEvents(object): .update(interface_id_seq=interface['id'])) def onLinkCreate(self, link, topology_id, client_id): + logger.debug("Link created %s", link) device_map = dict(Device.objects .filter(topology_id=topology_id, cid__in=[link['from_device_id'], link['to_device_id']]) .values_list('cid', 'pk')) @@ -141,6 +145,7 @@ class NetworkingEvents(object): .update(link_id_seq=link['id'])) def onLinkDestroy(self, link, topology_id, client_id): + logger.debug("Link deleted %s", link) device_map = dict(Device.objects .filter(topology_id=topology_id, cid__in=[link['from_device_id'], link['to_device_id']]) .values_list('cid', 'pk')) diff --git a/awx/network_ui/migrations/0001_initial.py b/awx/network_ui/migrations/0001_initial.py index 9b81e82455..07013104e1 100644 --- a/awx/network_ui/migrations/0001_initial.py +++ b/awx/network_ui/migrations/0001_initial.py @@ -11,7 +11,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('main', '0026_v330_emitted_events'), + ('main', '0027_v330_emitted_events'), ] operations = [ diff --git a/awx/playbooks/project_update.yml b/awx/playbooks/project_update.yml index 2564158bad..d9067bac45 100644 --- a/awx/playbooks/project_update.yml +++ b/awx/playbooks/project_update.yml @@ -40,7 +40,7 @@ - name: break if already checked out meta: end_play - when: scm_full_checkout|default('') and repo_check|succeeded and repo_check.before == scm_branch + when: scm_full_checkout|default('') and repo_check is succeeded and repo_check.before == scm_branch - name: update project using git git: @@ -139,7 +139,7 @@ register: doesRequirementsExist - name: fetch galaxy roles from requirements.yml - command: ansible-galaxy install -r requirements.yml -p {{project_path|quote}}/roles/ --force + command: ansible-galaxy install -r requirements.yml -p {{project_path|quote}}/roles/ {{ scm_result is defined|ternary('--force',omit) }} args: chdir: "{{project_path|quote}}/roles" when: doesRequirementsExist.stat.exists diff --git a/awx/plugins/inventory/cloudforms.py b/awx/plugins/inventory/cloudforms.py index 247d297e3d..c6702dcce1 100755 --- a/awx/plugins/inventory/cloudforms.py +++ b/awx/plugins/inventory/cloudforms.py @@ -138,7 +138,7 @@ class CloudFormsInventory(object): warnings.warn("No username specified, you need to specify a CloudForms username.") if config.has_option('cloudforms', 'password'): - self.cloudforms_pw = config.get('cloudforms', 'password') + self.cloudforms_pw = config.get('cloudforms', 'password', raw=True) else: self.cloudforms_pw = None diff --git a/awx/plugins/inventory/foreman.py b/awx/plugins/inventory/foreman.py index 4d6fd32b80..e1ca8787d6 100755 --- a/awx/plugins/inventory/foreman.py +++ b/awx/plugins/inventory/foreman.py @@ -84,7 +84,7 @@ class ForemanInventory(object): try: self.foreman_url = config.get('foreman', 'url') self.foreman_user = config.get('foreman', 'user') - self.foreman_pw = config.get('foreman', 'password') + self.foreman_pw = config.get('foreman', 'password', raw=True) self.foreman_ssl_verify = config.getboolean('foreman', 'ssl_verify') except (ConfigParser.NoOptionError, ConfigParser.NoSectionError) as e: print("Error parsing configuration: %s" % e, file=sys.stderr) diff --git a/awx/plugins/inventory/ovirt4.py b/awx/plugins/inventory/ovirt4.py index 53499220b9..cf2f7ad3c9 100755 --- a/awx/plugins/inventory/ovirt4.py +++ b/awx/plugins/inventory/ovirt4.py @@ -138,7 +138,7 @@ def create_connection(): return sdk.Connection( url=config.get('ovirt', 'ovirt_url'), username=config.get('ovirt', 'ovirt_username'), - password=config.get('ovirt', 'ovirt_password'), + password=config.get('ovirt', 'ovirt_password', raw=True), ca_file=config.get('ovirt', 'ovirt_ca_file'), insecure=config.get('ovirt', 'ovirt_ca_file') is None, ) diff --git a/awx/plugins/inventory/vmware_inventory.py b/awx/plugins/inventory/vmware_inventory.py index 7f3537bb4e..28977a3aed 100755 --- a/awx/plugins/inventory/vmware_inventory.py +++ b/awx/plugins/inventory/vmware_inventory.py @@ -268,7 +268,7 @@ class VMWareInventory(object): self.port = int(os.environ.get('VMWARE_PORT', config.get('vmware', 'port'))) self.username = os.environ.get('VMWARE_USERNAME', config.get('vmware', 'username')) self.debugl('username is %s' % self.username) - self.password = os.environ.get('VMWARE_PASSWORD', config.get('vmware', 'password')) + self.password = os.environ.get('VMWARE_PASSWORD', config.get('vmware', 'password', raw=True)) self.validate_certs = os.environ.get('VMWARE_VALIDATE_CERTS', config.get('vmware', 'validate_certs')) if self.validate_certs in ['no', 'false', 'False', False]: self.validate_certs = False @@ -287,11 +287,23 @@ class VMWareInventory(object): self.debugl('lower keys is %s' % self.lowerkeys) self.skip_keys = list(config.get('vmware', 'skip_keys').split(',')) self.debugl('skip keys is %s' % self.skip_keys) - self.host_filters = list(config.get('vmware', 'host_filters').split(',')) + temp_host_filters = list(config.get('vmware', 'host_filters').split('}},')) + for host_filter in temp_host_filters: + host_filter = host_filter.rstrip() + if host_filter != "": + if not host_filter.endswith("}}"): + host_filter += "}}" + self.host_filters.append(host_filter) self.debugl('host filters are %s' % self.host_filters) - self.groupby_patterns = list(config.get('vmware', 'groupby_patterns').split(',')) - self.debugl('groupby patterns are %s' % self.groupby_patterns) + temp_groupby_patterns = list(config.get('vmware', 'groupby_patterns').split('}},')) + for groupby_pattern in temp_groupby_patterns: + groupby_pattern = groupby_pattern.rstrip() + if groupby_pattern != "": + if not groupby_pattern.endswith("}}"): + groupby_pattern += "}}" + self.groupby_patterns.append(groupby_pattern) + self.debugl('groupby patterns are %s' % self.groupby_patterns) # Special feature to disable the brute force serialization of the # virtulmachine objects. The key name for these properties does not # matter because the values are just items for a larger list. @@ -491,7 +503,7 @@ class VMWareInventory(object): keylist = map(lambda x: x.strip(), tv['value'].split(',')) for kl in keylist: try: - newkey = self.config.get('vmware', 'custom_field_group_prefix') + field_name + '_' + kl + newkey = self.config.get('vmware', 'custom_field_group_prefix') + str(field_name) + '_' + kl newkey = newkey.strip() except Exception as e: self.debugl(e) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 013f831492..354c2b9e74 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -6,9 +6,9 @@ import re # noqa import sys import ldap import djcelery +import six from datetime import timedelta -from kombu import Queue, Exchange from kombu.common import Broadcast # global settings @@ -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 @@ -451,7 +455,7 @@ djcelery.setup_loader() BROKER_POOL_LIMIT = None BROKER_URL = 'amqp://guest:guest@localhost:5672//' CELERY_EVENT_QUEUE_TTL = 5 -CELERY_DEFAULT_QUEUE = 'tower' +CELERY_DEFAULT_QUEUE = 'awx_private_queue' CELERY_TASK_SERIALIZER = 'json' CELERY_RESULT_SERIALIZER = 'json' CELERY_ACCEPT_CONTENT = ['json'] @@ -463,11 +467,20 @@ CELERYD_AUTOSCALER = 'awx.main.utils.autoscale:DynamicAutoScaler' CELERY_RESULT_BACKEND = 'djcelery.backends.database:DatabaseBackend' CELERY_IMPORTS = ('awx.main.scheduler.tasks',) CELERY_QUEUES = ( - Queue('tower', Exchange('tower'), routing_key='tower'), - Broadcast('tower_broadcast_all') + Broadcast('tower_broadcast_all'), ) CELERY_ROUTES = {} + +def log_celery_failure(*args): + # Import annotations lazily to avoid polluting the `awx.settings` namespace + # and causing circular imports + from awx.main.tasks import log_celery_failure + return log_celery_failure(*args) + + +CELERY_ANNOTATIONS = {'*': {'on_failure': log_celery_failure}} + CELERYBEAT_SCHEDULER = 'celery.beat.PersistentScheduler' CELERYBEAT_MAX_LOOP_INTERVAL = 60 CELERYBEAT_SCHEDULE = { @@ -505,7 +518,13 @@ AWX_INCONSISTENT_TASK_INTERVAL = 60 * 3 # Celery queues that will always be listened to by celery workers # Note: Broadcast queues have unique, auto-generated names, with the alias # property value of the original queue name. -AWX_CELERY_QUEUES_STATIC = ['tower_broadcast_all',] +AWX_CELERY_QUEUES_STATIC = [ + six.text_type(CELERY_DEFAULT_QUEUE), +] + +AWX_CELERY_BCAST_QUEUES_STATIC = [ + six.text_type('tower_broadcast_all'), +] ASGI_AMQP = { 'INIT_FUNC': 'awx.prepare_env', @@ -629,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 diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 4b20ec165f..d4a2cc2280 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -182,13 +182,13 @@ class RADIUSBackend(BaseRADIUSBackend): Custom Radius backend to verify license status ''' - def authenticate(self, username, password): + def authenticate(self, request, username, password): if not django_settings.RADIUS_SERVER: return None if not feature_enabled('enterprise_auth'): logger.error("Unable to authenticate, license does not support RADIUS authentication") return None - return super(RADIUSBackend, self).authenticate(username, password) + return super(RADIUSBackend, self).authenticate(request, username, password) def get_user(self, user_id): if not django_settings.RADIUS_SERVER: diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 504b7724d4..75178d6d12 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -301,11 +301,12 @@ def _register_ldap(append=None): register( 'AUTH_LDAP{}_GROUP_TYPE_PARAMS'.format(append_str), field_class=fields.LDAPGroupTypeParamsField, - label=_('LDAP Group Type'), - help_text=_('Parameters to send the chosen group type.'), + label=_('LDAP Group Type Parameters'), + help_text=_('Key value parameters to send the chosen group type init method.'), category=_('LDAP'), category_slug='ldap', default=collections.OrderedDict([ + ('member_attr', 'member'), ('name_attr', 'cn'), ]), placeholder=collections.OrderedDict([ diff --git a/awx/sso/tests/unit/test_ldap.py b/awx/sso/tests/unit/test_ldap.py index 0a50871650..f8ad8264d7 100644 --- a/awx/sso/tests/unit/test_ldap.py +++ b/awx/sso/tests/unit/test_ldap.py @@ -2,6 +2,7 @@ import ldap from awx.sso.backends import LDAPSettings from awx.sso.validators import validate_ldap_filter +from django.core.cache import cache def test_ldap_default_settings(mocker): @@ -13,11 +14,11 @@ def test_ldap_default_settings(mocker): def test_ldap_default_network_timeout(mocker): + cache.clear() # clearing cache avoids picking up stray default for OPT_REFERRALS from_db = mocker.Mock(**{'order_by.return_value': []}) with mocker.patch('awx.conf.models.Setting.objects.filter', return_value=from_db): settings = LDAPSettings() assert settings.CONNECTION_OPTIONS == { - ldap.OPT_REFERRALS: 0, ldap.OPT_NETWORK_TIMEOUT: 30 } diff --git a/awx/ui/build/webpack.watch.js b/awx/ui/build/webpack.watch.js index d653707847..4450024a30 100644 --- a/awx/ui/build/webpack.watch.js +++ b/awx/ui/build/webpack.watch.js @@ -20,16 +20,16 @@ const watch = { output: { filename: OUTPUT }, - module: { - rules: [ - { - test: /\.js$/, - enforce: 'pre', - exclude: /node_modules/, - loader: 'eslint-loader' - } - ] - }, + module: { + rules: [ + { + test: /\.js$/, + enforce: 'pre', + exclude: /node_modules/, + loader: 'eslint-loader' + } + ] + }, plugins: [ new HtmlWebpackHarddiskPlugin(), new HardSourceWebpackPlugin({ @@ -51,27 +51,39 @@ const watch = { stats: 'minimal', publicPath: '/static/', host: '127.0.0.1', - https: true, + https: false, port: 3000, - https: true, - proxy: { - '/': { - target: TARGET, - secure: false, - ws: false, - bypass: req => req.originalUrl.includes('hot-update.json') - }, - '/websocket': { - target: TARGET, - secure: false, - ws: true - }, - '/network_ui': { - target: TARGET, - secure: false, - ws: true + clientLogLevel: 'none', + proxy: [{ + context: (pathname, req) => !(pathname === '/api/login/' && req.method === 'POST'), + target: TARGET, + secure: false, + ws: false, + bypass: req => req.originalUrl.includes('hot-update.json') + }, + { + context: '/api/login/', + target: TARGET, + secure: false, + ws: false, + headers: { + Host: `localhost:${TARGET_PORT}`, + Origin: TARGET, + Referer: `${TARGET}/` } - } + }, + { + context: '/websocket', + target: TARGET, + secure: false, + ws: true + }, + { + context: '/network_ui', + target: TARGET, + secure: false, + ws: true + }] } }; diff --git a/awx/ui/client/features/_index.less b/awx/ui/client/features/_index.less index e2339dc9e4..59e8e4630b 100644 --- a/awx/ui/client/features/_index.less +++ b/awx/ui/client/features/_index.less @@ -1,2 +1,3 @@ @import 'credentials/_index'; +@import 'output/_index'; @import 'users/tokens/_index'; diff --git a/awx/ui/client/features/index.js b/awx/ui/client/features/index.js index 763894c93c..0a6ec3864c 100644 --- a/awx/ui/client/features/index.js +++ b/awx/ui/client/features/index.js @@ -4,6 +4,7 @@ import atLibModels from '~models'; import atFeaturesApplications from '~features/applications'; import atFeaturesCredentials from '~features/credentials'; +import atFeaturesOutput from '~features/output'; import atFeaturesTemplates from '~features/templates'; import atFeaturesUsers from '~features/users'; import atFeaturesJobs from '~features/jobs'; @@ -18,7 +19,9 @@ angular.module(MODULE_NAME, [ atFeaturesCredentials, atFeaturesTemplates, atFeaturesUsers, - atFeaturesJobs + atFeaturesJobs, + atFeaturesOutput, + atFeaturesTemplates ]); export default MODULE_NAME; diff --git a/awx/ui/client/features/jobs/jobs.strings.js b/awx/ui/client/features/jobs/jobs.strings.js index a21c2b62e9..a902cc4109 100644 --- a/awx/ui/client/features/jobs/jobs.strings.js +++ b/awx/ui/client/features/jobs/jobs.strings.js @@ -7,11 +7,13 @@ function JobsStrings (BaseString) { ns.list = { ROW_ITEM_LABEL_STARTED: t.s('Started'), ROW_ITEM_LABEL_FINISHED: t.s('Finished'), + ROW_ITEM_LABEL_WORKFLOW_JOB: t.s('Workflow Job'), ROW_ITEM_LABEL_LAUNCHED_BY: t.s('Launched By'), ROW_ITEM_LABEL_JOB_TEMPLATE: t.s('Job Template'), ROW_ITEM_LABEL_INVENTORY: t.s('Inventory'), ROW_ITEM_LABEL_PROJECT: t.s('Project'), ROW_ITEM_LABEL_CREDENTIALS: t.s('Credentials'), + NO_RUNNING: t.s('There are no running jobs.') }; } diff --git a/awx/ui/client/features/jobs/jobsList.controller.js b/awx/ui/client/features/jobs/jobsList.controller.js index b78a1b77b6..4959d41b5e 100644 --- a/awx/ui/client/features/jobs/jobsList.controller.js +++ b/awx/ui/client/features/jobs/jobsList.controller.js @@ -29,6 +29,9 @@ function ListJobsController ( const iterator = 'job'; const key = 'job_dataset'; + let launchModalOpen = false; + let refreshAfterLaunchClose = false; + $scope.list = { iterator, name }; $scope.collection = { iterator, basePath: 'unified_jobs' }; $scope[key] = Dataset.data; @@ -38,12 +41,26 @@ function ListJobsController ( $scope[name] = dataset.results; }); $scope.$on('ws-jobs', () => { - qs.search(unifiedJob.path, $state.params.job_search) - .then(({ data }) => { - $scope.$emit('updateDataset', data); - }); + if (!launchModalOpen) { + refreshJobs(); + } else { + refreshAfterLaunchClose = true; + } }); + $scope.$on('launchModalOpen', (evt, isOpen) => { + evt.stopPropagation(); + if (!isOpen && refreshAfterLaunchClose) { + refreshAfterLaunchClose = false; + refreshJobs(); + } + launchModalOpen = isOpen; + }); + + if ($state.includes('instanceGroups')) { + vm.emptyListReason = strings.get('list.NO_RUNNING'); + } + vm.jobTypes = mapChoices(unifiedJob .options('actions.GET.type.choices')); @@ -52,19 +69,19 @@ function ListJobsController ( switch (type) { case 'job': - link = `/#/jobs/${id}`; + link = `/#/jobz/playbook/${id}`; break; case 'ad_hoc_command': - link = `/#/ad_hoc_commands/${id}`; + link = `/#/jobz/command/${id}`; break; case 'system_job': - link = `/#/management_jobs/${id}`; + link = `/#/jobz/system/${id}`; break; case 'project_update': - link = `/#/scm_update/${id}`; + link = `/#/jobz/project/${id}`; break; case 'inventory_update': - link = `/#/inventory_sync/${id}`; + link = `/#/jobz/inventory/${id}`; break; case 'workflow_job': link = `/#/workflows/${id}`; @@ -118,6 +135,55 @@ function ListJobsController ( actionText: 'DELETE' }); }; + + vm.cancelJob = (job) => { + const action = () => { + $('#prompt-modal').modal('hide'); + Wait('start'); + Rest.setUrl(job.related.cancel); + Rest.post() + .then(() => { + let reloadListStateParams = null; + + if ($scope.jobs.length === 1 && $state.params.job_search && + !_.isEmpty($state.params.job_search.page) && + $state.params.job_search.page !== '1') { + const page = `${(parseInt(reloadListStateParams + .job_search.page, 10) - 1)}`; + reloadListStateParams = _.cloneDeep($state.params); + reloadListStateParams.job_search.page = page; + } + + $state.go('.', reloadListStateParams, { reload: true }); + }) + .catch(({ data, status }) => { + ProcessErrors($scope, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { path: `${job.url}`, status }) + }); + }) + .finally(() => { + Wait('stop'); + }); + }; + + const deleteModalBody = `
${strings.get('cancelJob.SUBMIT_REQUEST')}
`; + + Prompt({ + hdr: strings.get('cancelJob.HEADER'), + resourceName: $filter('sanitize')(job.name), + body: deleteModalBody, + action, + actionText: strings.get('CANCEL') + }); + }; + + function refreshJobs () { + qs.search(unifiedJob.path, $state.params.job_search) + .then(({ data }) => { + $scope.$emit('updateDataset', data); + }); + } } ListJobsController.$inject = [ diff --git a/awx/ui/client/features/jobs/jobsList.view.html b/awx/ui/client/features/jobs/jobsList.view.html index 6031a956ab..d339ee75f1 100644 --- a/awx/ui/client/features/jobs/jobsList.view.html +++ b/awx/ui/client/features/jobs/jobsList.view.html @@ -12,7 +12,7 @@ query-set="querySet"> - +
@@ -24,15 +24,22 @@ header-link="{{ vm.getLink(job) }}" header-tag="{{ vm.jobTypes[job.type] }}"> +
+ + + + +
- - + label-value="{{:: vm.strings.get('list.ROW_ITEM_LABEL_WORKFLOW_JOB') }}" + value="{{ job.summary_fields.source_workflow_job.name }}" + value-link="/#/workflows/{{ job.summary_fields.source_workflow_job.id }}"> - +
+ + + ng-show="job.summary_fields.user_capabilities.delete && + !(job.status === 'pending' || + job.status === 'waiting' || + job.status === 'running')">
diff --git a/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js b/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js index fe86b78774..7bfda756bc 100644 --- a/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js +++ b/awx/ui/client/features/jobs/routes/instanceGroupJobs.route.js @@ -16,7 +16,8 @@ export default { job_search: { value: { page_size: '10', - order_by: '-finished' + order_by: '-id', + status: 'running' }, dynamic: true } diff --git a/awx/ui/client/features/output/_index.less b/awx/ui/client/features/output/_index.less new file mode 100644 index 0000000000..4238573fb3 --- /dev/null +++ b/awx/ui/client/features/output/_index.less @@ -0,0 +1,530 @@ +@import 'host-event/_index'; +.at-Stdout { + &-menuTop { + color: @at-gray-848992; + border: 1px solid @at-gray-b7; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom: none; + + & > div { + user-select: none; + } + } + + &-menuBottom { + color: @at-gray-848992; + font-size: 10px; + text-transform: uppercase; + font-weight: bold; + position: absolute; + right: 60px; + bottom: 24px; + cursor: pointer; + + &:hover { + color: @at-blue; + } + } + + &-menuIconGroup { + & > p { + margin: 0; + } + + & > p:first-child { + font-size: 20px; + margin-right: 8px; + } + + & > p:last-child { + margin-top: 9px; + } + } + + &-menuIcon { + font-size: 12px; + padding: 10px; + cursor: pointer; + + &:hover { + color: @at-blue; + } + } + + &-menuIcon--lg { + font-size: 22px; + line-height: 12px; + font-weight: bold; + padding: 10px; + cursor: pointer; + + &:hover { + color: @at-blue; + } + } + + &-menuIcon--active { + font-size: 22px; + line-height: 12px; + font-weight: bold; + padding: 10px; + cursor: pointer; + color: @at-blue; + } + + &-toggle { + color: @at-gray-848992; + background-color: @at-gray-eb; + font-size: 18px; + line-height: 12px; + + & > i { + cursor: pointer; + } + + padding: 0 10px 0 10px; + user-select: none; + } + + &-line { + color: @at-gray-161b1f; + background-color: @at-gray-eb; + text-align: right; + vertical-align: top; + padding-right: 5px; + border-right: 1px solid @at-gray-b7; + user-select: none; + } + + &-event { + .at-mixin-event(); + } + + &-event--host { + .at-mixin-event(); + + cursor: pointer; + } + + &-time { + padding-right: 2ch; + font-size: 12px; + text-align: right; + user-select: none; + width: 11ch; + + & > span { + background-color: white; + border-radius: 4px; + padding: 1px 2px; + } + } + + &-container { + font-family: monospace; + height: calc(~"100vh - 240px"); + overflow-y: scroll; + font-size: 15px; + border: 1px solid @at-gray-b7; + background-color: @at-gray-f2; + color: @at-gray-161b1f; + padding: 0; + margin: 0; + border-radius: 0; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + + & > table { + table-layout: fixed; + + tr:hover > td { + background: white; + } + } + } +} + +.at-mixin-event() { + padding: 0 10px; + word-wrap: break-word; + white-space: pre-wrap; + +} + +// Search --------------------------------------------------------------------------------- +@at-jobz-top-search-key: @at-space-2x; +@at-jobz-bottom-search-key: @at-space-3x; + +.jobz-searchKeyPaneContainer { + margin-top: @at-jobz-top-search-key; + margin-bottom: @at-jobz-bottom-search-key; +} + +.jobz-searchKeyPane { + // background-color: @at-gray-f6; + background-color: @login-notice-bg; + color: @login-notice-text; + border-radius: @at-border-radius; + border: 1px solid @at-gray-b7; + // color: @at-gray-848992; + padding: 6px @at-padding-input 6px @at-padding-input; +} + +.jobz-searchClearAll { + font-size: 10px; + padding-bottom: @at-space; +} + +.jobz-Button-searchKey { + .at-mixin-Button(); + + background-color: @at-blue; + border-color: at-color-button-border-default; + color: @at-white; + + &:hover, &:active { + color: @at-white; + background-color: @at-blue-hover; + box-shadow: none; + } + + &:focus { + color: @at-white; + } +} + +.jobz-tagz { + margin-top: @at-space; + display: flex; + width: 100%; + flex-wrap: wrap; +} + + +// Status Bar ----------------------------------------------------------------------------- +.HostStatusBar { + display: flex; + flex: 0 0 auto; + width: 100%; +} + +.HostStatusBar-ok, +.HostStatusBar-changed, +.HostStatusBar-dark, +.HostStatusBar-failed, +.HostStatusBar-skipped, +.HostStatusBar-noData { + height: 15px; + border-top: 5px solid @default-bg; + border-bottom: 5px solid @default-bg; +} + +.HostStatusBar-ok { + background-color: @default-succ; + display: flex; + flex: 0 0 auto; +} + +.HostStatusBar-changed { + background-color: @default-warning; + flex: 0 0 auto; +} + +.HostStatusBar-dark { + background-color: @default-unreachable; + flex: 0 0 auto; +} + +.HostStatusBar-failed { + background-color: @default-err; + flex: 0 0 auto; +} + +.HostStatusBar-skipped { + background-color: @default-link; + flex: 0 0 auto; +} + +.HostStatusBar-noData { + background-color: @default-icon-hov; + flex: 1 0 auto; +} + +.HostStatusBar-tooltipLabel { + text-transform: uppercase; + margin-right: 15px; +} + +.HostStatusBar-tooltipBadge { + border-radius: 5px; + border: 1px solid @default-bg; +} + +.HostStatusBar-tooltipBadge--ok { + background-color: @default-succ; +} + +.HostStatusBar-tooltipBadge--dark { + background-color: @default-unreachable; +} + +.HostStatusBar-tooltipBadge--skipped { + background-color: @default-link; +} + +.HostStatusBar-tooltipBadge--changed { + background-color: @default-warning; +} + +.HostStatusBar-tooltipBadge--failed { + background-color: @default-err; + +} + +// Job Details --------------------------------------------------------------------------------- + +@breakpoint-md: 1200px; + +.JobResults { + .OnePlusTwo-container(100%, @breakpoint-md); + + &.fullscreen { + .JobResults-rightSide { + max-width: 100%; + } + } +} + +.JobResults-leftSide { + .OnePlusTwo-left--panel(100%, @breakpoint-md); + max-width: 30%; + height: ~"calc(100vh - 177px)"; + + @media screen and (max-width: @breakpoint-md) { + max-width: 100%; + } +} + +.JobResults-rightSide { + .OnePlusTwo-right--panel(100%, @breakpoint-md); + height: ~"calc(100vh - 177px)"; + + @media (max-width: @breakpoint-md - 1px) { + padding-right: 15px; + } +} + +.JobResults-detailsPanel{ + overflow-y: scroll; +} + +.JobResults-stdoutActionButton--active { + display: none; + visibility: hidden; + flex:none; + width:0px; + padding-right: 0px; +} + +.JobResults-panelHeader { + display: flex; + height: 30px; +} + +.JobResults-panelHeaderText { + color: @default-interface-txt; + flex: 1 0 auto; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; +} + +.JobResults-panelHeaderButtonActions { + display: flex; +} + +.JobResults-resultRow { + width: 100%; + display: flex; + padding-bottom: 10px; + flex-wrap: wrap; +} + +.JobResults-codeMirrorResultRowLabel{ + font-size: 12px; +} + +.JobResults-resultRowLabel { + text-transform: uppercase; + color: @default-interface-txt; + font-size: 12px; + font-weight: normal!important; + width: 30%; + margin-right: 20px; + + @media screen and (max-width: @breakpoint-md) { + flex: 2.5 0 auto; + } +} + +.JobResults-resultRowLabel--fullWidth { + width: 100%; + margin-right: 0px; +} + +.JobResults-resultRowText { + width: ~"calc(70% - 20px)"; + flex: 1 0 auto; + text-transform: none; + word-wrap: break-word; +} + +.JobResults-resultRowText--fullWidth { + width: 100%; +} + +.JobResults-expandArrow { + color: #D7D7D7; + font-size: 14px; + font-weight: bold; + margin-right: 10px; + text-transform: uppercase; + margin-left: 10px; +} + +.JobResults-resultRowText--instanceGroup { + display: flex; +} + +.JobResults-isolatedBadge { + align-items: center; + background-color: @default-list-header-bg; + border-radius: 5px; + color: @default-stdout-txt; + display: flex; + font-size: 10px; + height: 16px; + margin: 3px 0 0 10px; + padding: 0 10px; + text-transform: uppercase; +} + +.JobResults-statusResultIcon { + padding-left: 0px; + padding-right: 10px; +} + +.JobResults-badgeRow { + display: flex; + align-items: center; + margin-right: 5px; +} + +.JobResults-badgeTitle{ + color: @default-interface-txt; + font-size: 14px; + margin-right: 10px; + font-weight: normal; + text-transform: uppercase; + margin-left: 20px; +} + +@media (max-width: @breakpoint-md) { + .JobResults-detailsPanel { + overflow-y: auto; + } + + .JobResults-rightSide { + height: inherit; + } +} + +.JobResults-timeBadge { + float:right; + font-size: 11px; + font-weight: normal; + padding: 1px 10px; + height: 14px; + margin: 3px 15px; + width: 80px; + background-color: @default-bg; + border-radius: 5px; + color: @default-interface-txt; + margin-right: -5px; +} + +.JobResults-panelRight { + display: flex; + flex-direction: column; +} + +.JobResults-panelRight .SmartSearch-bar { + width: 100%; +} + +.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; +} + +.StandardOut-panelHeader--jobIsRunning { + margin-bottom: 20px; +} + +host-status-bar { + flex: initial; + margin-bottom: 20px; +} + +smart-search { + flex: initial; +} + +job-results-standard-out { + flex: 1; + flex-basis: auto; + height: ~"calc(100% - 800px)"; + display: flex; + border: 1px solid @d7grey; + border-radius: 5px; + margin-top: 20px; +} +@media screen and (max-width: @breakpoint-md) { + job-results-standard-out { + height: auto; + } +} + +.JobResults-extraVarsHelp { + margin-left: 10px; + color: @default-icon; +} + +.JobResults-seeMoreLess { + color: #337AB7; + margin: 4px 0px; + text-transform: uppercase; + padding: 2px 0px; + cursor: pointer; + border-radius: 5px; + font-size: 11px; +} diff --git a/awx/ui/client/features/output/details.directive.js b/awx/ui/client/features/output/details.directive.js new file mode 100644 index 0000000000..eb2b7277c7 --- /dev/null +++ b/awx/ui/client/features/output/details.directive.js @@ -0,0 +1,609 @@ +const templateUrl = require('~features/output/details.partial.html'); + +let $http; +let $filter; +let $scope; +let $state; + +let error; +let parse; +let prompt; +let resource; +let strings; +let status; +let wait; + +let vm; + +function mapChoices (choices) { + if (!choices) return {}; + return Object.assign(...choices.map(([k, v]) => ({ [k]: v }))); +} + +function getStatusDetails (jobStatus) { + const unmapped = jobStatus || resource.model.get('status'); + + if (!unmapped) { + return null; + } + + const choices = mapChoices(resource.model.options('actions.GET.status.choices')); + + const label = 'Status'; + const icon = `fa icon-job-${unmapped}`; + const value = choices[unmapped]; + + return { label, icon, value }; +} + +function getStartDetails (started) { + const unfiltered = started || resource.model.get('started'); + + const label = 'Started'; + + let value; + + if (unfiltered) { + value = $filter('longDate')(unfiltered); + } else { + value = 'Not Started'; + } + + return { label, value }; +} + +function getFinishDetails (finished) { + const unfiltered = finished || resource.model.get('finished'); + + const label = 'Finished'; + + let value; + + if (unfiltered) { + value = $filter('longDate')(unfiltered); + } else { + value = 'Not Finished'; + } + + return { label, value }; +} + +function getModuleArgDetails () { + const value = resource.model.get('module_args'); + const label = 'Module Args'; + + if (!value) { + return null; + } + + return { label, value }; +} + +function getJobTypeDetails () { + const unmapped = resource.model.get('job_type'); + + if (!unmapped) { + return null; + } + + const choices = mapChoices(resource.model.options('actions.GET.job_type.choices')); + + const label = 'Job Type'; + const value = choices[unmapped]; + + return { label, value }; +} + +function getVerbosityDetails () { + const verbosity = resource.model.get('verbosity'); + + if (!verbosity) { + return null; + } + + const choices = mapChoices(resource.model.options('actions.GET.verbosity.choices')); + + const label = 'Verbosity'; + const value = choices[verbosity]; + + return { label, value }; +} + +function getSourceWorkflowJobDetails () { + const sourceWorkflowJob = resource.model.get('summary_fields.source_workflow_job'); + + if (!sourceWorkflowJob) { + return null; + } + + const link = `/#/workflows/${sourceWorkflowJob.id}`; + const tooltip = strings.get('resourceTooltips.SOURCE_WORKFLOW_JOB'); + + return { link, tooltip }; +} + +function getJobTemplateDetails () { + const jobTemplate = resource.model.get('summary_fields.job_template'); + + if (!jobTemplate) { + return null; + } + + const label = 'Job Template'; + const link = `/#/templates/job_template/${jobTemplate.id}`; + const value = $filter('sanitize')(jobTemplate.name); + const tooltip = strings.get('resourceTooltips.JOB_TEMPLATE'); + + return { label, link, value, tooltip }; +} + +function getLaunchedByDetails () { + const createdBy = resource.model.get('summary_fields.created_by'); + const jobTemplate = resource.model.get('summary_fields.job_template'); + + const relatedSchedule = resource.model.get('related.schedule'); + const schedule = resource.model.get('summary_fields.schedule'); + + if (!createdBy && !schedule) { + return null; + } + + const label = 'Launched By'; + + let link; + let tooltip; + let value; + + if (createdBy) { + tooltip = strings.get('resourceTooltips.USER'); + link = `/#/users/${createdBy.id}`; + value = $filter('sanitize')(createdBy.username); + } else if (relatedSchedule && jobTemplate) { + tooltip = strings.get('resourceTooltips.SCHEDULE'); + link = `/#/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`; + value = $filter('sanitize')(schedule.name); + } else { + tooltip = null; + link = null; + value = $filter('sanitize')(schedule.name); + } + + return { label, link, tooltip, value }; +} + +function getInventoryDetails () { + const inventory = resource.model.get('summary_fields.inventory'); + + if (!inventory) { + return null; + } + + const label = 'Inventory'; + const tooltip = strings.get('resourceTooltips.INVENTORY'); + const value = $filter('sanitize')(inventory.name); + + let link; + + if (inventory.kind === 'smart') { + link = `/#/inventories/smart/${inventory.id}`; + } else { + link = `/#/inventories/inventory/${inventory.id}`; + } + + return { label, link, tooltip, value }; +} + +function getProjectDetails () { + const project = resource.model.get('summary_fields.project'); + + if (!project) { + return null; + } + + const label = 'Project'; + const link = `/#/projects/${project.id}`; + const value = $filter('sanitize')(project.name); + const tooltip = strings.get('resourceTooltips.PROJECT'); + + return { label, link, value, tooltip }; +} + +function getProjectStatusDetails (projectStatus) { + const project = resource.model.get('summary_fields.project'); + const jobStatus = projectStatus || resource.model.get('summary_fields.project_update.status'); + + if (!project) { + return null; + } + + return jobStatus; +} + +function getProjectUpdateDetails (updateId) { + const project = resource.model.get('summary_fields.project'); + const jobId = updateId || resource.model.get('summary_fields.project_update.id'); + + if (!project) { + return null; + } + + const link = `/#/jobz/project/${jobId}`; + const tooltip = strings.get('resourceTooltips.PROJECT_UPDATE'); + + return { link, tooltip }; +} + +function getSCMRevisionDetails () { + const label = 'Revision'; + const value = resource.model.get('scm_revision'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getPlaybookDetails () { + const label = 'Playbook'; + const value = resource.model.get('playbook'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getJobExplanationDetails () { + const explanation = resource.model.get('job_explanation'); + + if (!explanation) { + return null; + } + + const limit = 150; + const label = 'Explanation'; + + let more = explanation; + + if (explanation.split(':')[0] === 'Previous Task Failed') { + const taskStringIndex = explanation.split(':')[0].length + 1; + const task = JSON.parse(explanation.substring(taskStringIndex)); + + more = `${task.job_type} failed for ${task.job_name} with ID ${task.job_id}`; + } + + const less = $filter('limitTo')(more, limit); + + const showMore = false; + const hasMoreToShow = more.length > limit; + + return { label, less, more, showMore, hasMoreToShow }; +} + +function getResultTracebackDetails () { + const traceback = resource.model.get('result_traceback'); + + if (!traceback) { + return null; + } + + const limit = 150; + const label = 'Results Traceback'; + + const more = traceback; + const less = $filter('limitTo')(more, limit); + + const showMore = false; + const hasMoreToShow = more.length > limit; + + return { label, less, more, showMore, hasMoreToShow }; +} + +function getCredentialDetails () { + const credential = resource.model.get('summary_fields.credential'); + + if (!credential) { + return null; + } + + let label = 'Credential'; + + if (resource.type === 'playbook') { + label = 'Machine Credential'; + } + + if (resource.type === 'inventory') { + label = 'Source Credential'; + } + + const link = `/#/credentials/${credential.id}`; + const tooltip = strings.get('resourceTooltips.CREDENTIAL'); + const value = $filter('sanitize')(credential.name); + + return { label, link, tooltip, value }; +} + +function getForkDetails () { + const label = 'Forks'; + const value = resource.model.get('forks'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getLimitDetails () { + const label = 'Limit'; + const value = resource.model.get('limit'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getInstanceGroupDetails () { + const instanceGroup = resource.model.get('summary_fields.instance_group'); + + if (!instanceGroup) { + return null; + } + + const label = 'Instance Group'; + const value = $filter('sanitize')(instanceGroup.name); + + let isolated = null; + + if (instanceGroup.controller_id) { + isolated = 'Isolated'; + } + + return { label, value, isolated }; +} + +function getJobTagDetails () { + const label = 'Job Tags'; + const value = resource.model.get('job_tags'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getSkipTagDetails () { + const label = 'Skip Tags'; + const value = resource.model.get('skip_tags'); + + if (!value) { + return null; + } + + return { label, value }; +} + +function getExtraVarsDetails () { + const extraVars = resource.model.get('extra_vars'); + + if (!extraVars) { + return null; + } + + const label = 'Extra Variables'; + const tooltip = 'Read-only view of extra variables added to the job template.'; + const value = parse(extraVars); + const disabled = true; + + return { label, tooltip, value, disabled }; +} + +function getLabelDetails () { + const jobLabels = _.get(resource.model.get('related.labels'), 'results', []); + + if (jobLabels.length < 1) { + return null; + } + + const label = 'Labels'; + const more = false; + + const value = jobLabels.map(({ name }) => name).map($filter('sanitize')); + + return { label, more, value }; +} + +function createErrorHandler (path, action) { + return res => { + const hdr = strings.get('error.HEADER'); + const msg = strings.get('error.CALL', { path, action, status: res.status }); + + error($scope, res.data, res.status, null, { hdr, msg }); + }; +} + +const ELEMENT_LABELS = '#job-results-labels'; +const ELEMENT_PROMPT_MODAL = '#prompt-modal'; +const LABELS_SLIDE_DISTANCE = 200; + +function toggleLabels () { + if (!this.labels.more) { + $(ELEMENT_LABELS).slideUp(LABELS_SLIDE_DISTANCE); + this.labels.more = true; + } else { + $(ELEMENT_LABELS).slideDown(LABELS_SLIDE_DISTANCE); + this.labels.more = false; + } +} + +function cancelJob () { + const actionText = strings.get('warnings.CANCEL_ACTION'); + const hdr = strings.get('warnings.CANCEL_HEADER'); + const warning = strings.get('warnings.CANCEL_BODY'); + + const id = resource.model.get('id'); + const name = $filter('sanitize')(resource.model.get('name')); + + const body = `
${warning}
`; + const resourceName = `#${id} ${name}`; + + const method = 'POST'; + const url = `${resource.model.path}${id}/cancel/`; + + const errorHandler = createErrorHandler('cancel job', method); + + const action = () => { + wait('start'); + $http({ method, url }) + .catch(errorHandler) + .finally(() => { + $(ELEMENT_PROMPT_MODAL).modal('hide'); + wait('stop'); + }); + }; + + prompt({ hdr, resourceName, body, actionText, action }); +} + +function deleteJob () { + const actionText = strings.get('DELETE'); + const hdr = strings.get('warnings.DELETE_HEADER'); + const warning = strings.get('warnings.DELETE_BODY'); + + const id = resource.model.get('id'); + const name = $filter('sanitize')(resource.model.get('name')); + + const body = `
${warning}
`; + const resourceName = `#${id} ${name}`; + + const method = 'DELETE'; + const url = `${resource.model.path}${id}/`; + + const errorHandler = createErrorHandler('delete job', method); + + const action = () => { + wait('start'); + $http({ method, url }) + .then(() => $state.go('jobs')) + .catch(errorHandler) + .finally(() => { + $(ELEMENT_PROMPT_MODAL).modal('hide'); + wait('stop'); + }); + }; + + prompt({ hdr, resourceName, body, actionText, action }); +} + +function AtJobDetailsController ( + _$http_, + _$filter_, + _$state_, + _error_, + _prompt_, + _strings_, + _status_, + _wait_, + ParseTypeChange, + ParseVariableString, +) { + vm = this || {}; + + $http = _$http_; + $filter = _$filter_; + $state = _$state_; + + error = _error_; + parse = ParseVariableString; + prompt = _prompt_; + strings = _strings_; + status = _status_; + wait = _wait_; + + vm.init = _$scope_ => { + $scope = _$scope_; + resource = $scope.resource; // eslint-disable-line prefer-destructuring + + vm.status = getStatusDetails(); + vm.started = getStartDetails(); + vm.finished = getFinishDetails(); + vm.moduleArgs = getModuleArgDetails(); + vm.jobType = getJobTypeDetails(); + vm.jobTemplate = getJobTemplateDetails(); + vm.sourceWorkflowJob = getSourceWorkflowJobDetails(); + vm.inventory = getInventoryDetails(); + vm.project = getProjectDetails(); + vm.projectUpdate = getProjectUpdateDetails(); + vm.projectStatus = getProjectStatusDetails(); + vm.scmRevision = getSCMRevisionDetails(); + vm.playbook = getPlaybookDetails(); + vm.resultTraceback = getResultTracebackDetails(); + vm.launchedBy = getLaunchedByDetails(); + vm.jobExplanation = getJobExplanationDetails(); + vm.verbosity = getVerbosityDetails(); + vm.credential = getCredentialDetails(); + vm.forks = getForkDetails(); + vm.limit = getLimitDetails(); + vm.instanceGroup = getInstanceGroupDetails(); + vm.jobTags = getJobTagDetails(); + vm.skipTags = getSkipTagDetails(); + vm.extraVars = getExtraVarsDetails(); + vm.labels = getLabelDetails(); + + // Relaunch and Delete Components + vm.job = _.get(resource.model, 'model.GET', {}); + vm.canDelete = resource.model.get('summary_fields.user_capabilities.delete'); + + vm.cancelJob = cancelJob; + vm.deleteJob = deleteJob; + vm.toggleLabels = toggleLabels; + + const observe = (getter, transform, key) => { + $scope.$watch(getter, value => { vm[key] = transform(value); }); + }; + + observe(status.getStarted, getStartDetails, 'started'); + observe(status.getJobStatus, getStatusDetails, 'status'); + observe(status.getFinished, getFinishDetails, 'finished'); + observe(status.getProjectUpdateId, getProjectUpdateDetails, 'projectUpdate'); + observe(status.getProjectStatus, getProjectStatusDetails, 'projectStatus'); + }; +} + +AtJobDetailsController.$inject = [ + '$http', + '$filter', + '$state', + 'ProcessErrors', + 'Prompt', + 'JobStrings', + 'JobStatusService', + 'Wait', + 'ParseTypeChange', + 'ParseVariableString', +]; + +function atJobDetailsLink (scope, el, attrs, controllers) { + const [atDetailsController] = controllers; + + atDetailsController.init(scope); +} + +function atJobDetails () { + return { + templateUrl, + restrict: 'E', + require: ['atJobDetails'], + controllerAs: 'vm', + link: atJobDetailsLink, + controller: AtJobDetailsController, + scope: { resource: '=', }, + }; +} + +export default atJobDetails; diff --git a/awx/ui/client/features/output/details.partial.html b/awx/ui/client/features/output/details.partial.html new file mode 100644 index 0000000000..d873c4fbb5 --- /dev/null +++ b/awx/ui/client/features/output/details.partial.html @@ -0,0 +1,287 @@ + +
+
DETAILS
+ +
+ + + + + + + + +
+
+ + +
+ +
+ +
+ + {{ vm.status.value }} +
+
+ + +
+ +
+ {{ vm.jobExplanation.less }} + ... + + Show More + +
+
+ {{ vm.jobExplanation.more }} + + Show Less + +
+
+ + +
+ +
+ {{ vm.started.value }} +
+
+ + +
+ +
+ {{ vm.finished.value }} +
+
+ + +
+ +
{{ vm.moduleArgs.value }}
+
+ + +
+ +
+ {{ vm.resultTraceback.less }} + ... + + Show More + +
+
+ {{ vm.resultTraceback.more }} + + Show Less + +
+
+ + +
+ + +
+ + +
+ +
{{ vm.jobType.value }}
+
+ + +
+ + +
+ {{ vm.launchedBy.value }} +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
{{ vm.playbook.value }}
+
+ + +
+ + +
+ + +
+ +
{{ vm.forks.value }}
+
+ + +
+ +
{{ vm.limit.value }}
+
+ + +
+ +
{{ vm.verbosity.value }}
+
+ + +
+ +
+ {{ vm.instanceGroup.value }} + + {{ vm.instanceGroup.isolated }} + +
+
+ + +
+ +
{{ vm.jobTags.value }}
+
+ + +
+ +
{{ vm.skipTags.value }}
+
+ + + + + + +
+ +
+
+
{{ label }}
+
+
+
+
diff --git a/awx/ui/client/features/output/engine.service.js b/awx/ui/client/features/output/engine.service.js new file mode 100644 index 0000000000..1f74a90c59 --- /dev/null +++ b/awx/ui/client/features/output/engine.service.js @@ -0,0 +1,226 @@ +const JOB_END = 'playbook_on_stats'; +const MAX_LAG = 120; + +function JobEventEngine ($q) { + this.init = ({ resource, scroll, page, onEventFrame, onStart, onStop }) => { + this.resource = resource; + this.scroll = scroll; + this.page = page; + + this.lag = 0; + this.count = 0; + this.pageCount = 0; + this.chain = $q.resolve(); + this.factors = this.getBatchFactors(this.resource.page.size); + + this.state = { + started: false, + paused: false, + pausing: false, + resuming: false, + ending: false, + ended: false, + counting: false, + }; + + this.hooks = { + onEventFrame, + onStart, + onStop, + }; + + this.lines = { + used: [], + missing: [], + ready: false, + min: 0, + max: 0 + }; + }; + + this.getBatchFactors = size => { + const factors = [1]; + + for (let i = 2; i <= size / 2; i++) { + if (size % i === 0) { + factors.push(i); + } + } + + factors.push(size); + + return factors; + }; + + this.getBatchFactorIndex = () => { + const index = Math.floor((this.lag / MAX_LAG) * this.factors.length); + + return index > this.factors.length - 1 ? this.factors.length - 1 : index; + }; + + this.setBatchFrameCount = () => { + const index = this.getBatchFactorIndex(); + + this.framesPerRender = this.factors[index]; + }; + + this.buffer = data => { + const pageAdded = this.page.addToBuffer(data); + + this.pageCount++; + + if (pageAdded) { + this.setBatchFrameCount(); + + if (this.isPausing()) { + this.pause(true); + } else if (this.isResuming()) { + this.resume(true); + } + } + }; + + this.checkLines = data => { + for (let i = data.start_line; i < data.end_line; i++) { + if (i > this.lines.max) { + this.lines.max = i; + } + + this.lines.used.push(i); + } + + const missing = []; + for (let i = this.lines.min; i < this.lines.max; i++) { + if (this.lines.used.indexOf(i) === -1) { + missing.push(i); + } + } + + if (missing.length === 0) { + this.lines.ready = true; + this.lines.min = this.lines.max + 1; + this.lines.used = []; + } else { + this.lines.ready = false; + } + }; + + this.pushJobEvent = data => { + this.lag++; + + this.chain = this.chain + .then(() => { + if (!this.isActive()) { + this.start(); + } else if (data.event === JOB_END) { + if (this.isPaused()) { + this.end(true); + } else { + this.end(); + } + } + + this.checkLines(data); + this.buffer(data); + this.count++; + + if (!this.isReadyToRender()) { + return $q.resolve(); + } + + const events = this.page.emptyBuffer(); + this.count -= events.length; + + return this.renderFrame(events); + }) + .then(() => --this.lag); + + return this.chain; + }; + + this.renderFrame = events => this.hooks.onEventFrame(events) + .then(() => { + if (this.scroll.isLocked()) { + this.scroll.scrollToBottom(); + } + + if (this.isEnding()) { + const lastEvents = this.page.emptyBuffer(); + + if (lastEvents.length) { + return this.renderFrame(lastEvents); + } + + this.end(true); + } + + return $q.resolve(); + }); + + this.resume = done => { + if (done) { + this.state.resuming = false; + this.state.paused = false; + } else if (!this.isTransitioning()) { + this.scroll.pause(); + this.scroll.lock(); + this.scroll.scrollToBottom(); + this.state.resuming = true; + this.page.removeBookmark(); + } + }; + + this.pause = done => { + if (done) { + this.state.pausing = false; + this.state.paused = true; + this.scroll.resume(); + } else if (!this.isTransitioning()) { + this.scroll.pause(); + this.scroll.unlock(); + this.state.pausing = true; + this.page.setBookmark(); + } + }; + + this.start = () => { + if (!this.state.ending && !this.state.ended) { + this.state.started = true; + this.scroll.pause(); + this.scroll.lock(); + + this.hooks.onStart(); + } + }; + + this.end = done => { + if (done) { + this.state.ending = false; + this.state.ended = true; + this.scroll.unlock(); + this.scroll.resume(); + + this.hooks.onStop(); + + return; + } + + this.state.ending = true; + }; + + this.isReadyToRender = () => this.isDone() || + (!this.isPaused() && this.hasAllLines() && this.isBatchFull()); + this.hasAllLines = () => this.lines.ready; + this.isBatchFull = () => this.count % this.framesPerRender === 0; + this.isPaused = () => this.state.paused; + this.isPausing = () => this.state.pausing; + this.isResuming = () => this.state.resuming; + this.isTransitioning = () => this.isActive() && (this.state.pausing || this.state.resuming); + this.isActive = () => this.state.started && !this.state.ended; + this.isEnding = () => this.state.ending; + this.isDone = () => this.state.ended; +} + +JobEventEngine.$inject = ['$q']; + +export default JobEventEngine; diff --git a/awx/ui/client/src/job-results/host-event/host-event.block.less b/awx/ui/client/features/output/host-event/_index.less similarity index 99% rename from awx/ui/client/src/job-results/host-event/host-event.block.less rename to awx/ui/client/features/output/host-event/_index.less index 6153466934..bec8548cd2 100644 --- a/awx/ui/client/src/job-results/host-event/host-event.block.less +++ b/awx/ui/client/features/output/host-event/_index.less @@ -15,6 +15,7 @@ } .HostEvent .CodeMirror{ overflow-x: hidden; + max-height: none!important; } .HostEvent-close:hover{ diff --git a/awx/ui/client/src/job-results/host-event/host-event-codemirror.partial.html b/awx/ui/client/features/output/host-event/host-event-codemirror.partial.html similarity index 100% rename from awx/ui/client/src/job-results/host-event/host-event-codemirror.partial.html rename to awx/ui/client/features/output/host-event/host-event-codemirror.partial.html diff --git a/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html b/awx/ui/client/features/output/host-event/host-event-modal.partial.html similarity index 83% rename from awx/ui/client/src/job-results/host-event/host-event-modal.partial.html rename to awx/ui/client/features/output/host-event/host-event-modal.partial.html index 7da83dfb43..a79b3cde68 100644 --- a/awx/ui/client/src/job-results/host-event/host-event-modal.partial.html +++ b/awx/ui/client/features/output/host-event/host-event-modal.partial.html @@ -40,19 +40,19 @@
- - - diff --git a/awx/ui/client/src/job-results/host-event/host-event-stderr.partial.html b/awx/ui/client/features/output/host-event/host-event-stderr.partial.html similarity index 100% rename from awx/ui/client/src/job-results/host-event/host-event-stderr.partial.html rename to awx/ui/client/features/output/host-event/host-event-stderr.partial.html diff --git a/awx/ui/client/src/job-results/host-event/host-event-stdout.partial.html b/awx/ui/client/features/output/host-event/host-event-stdout.partial.html similarity index 100% rename from awx/ui/client/src/job-results/host-event/host-event-stdout.partial.html rename to awx/ui/client/features/output/host-event/host-event-stdout.partial.html diff --git a/awx/ui/client/features/output/host-event/host-event.controller.js b/awx/ui/client/features/output/host-event/host-event.controller.js new file mode 100644 index 0000000000..a688e59a64 --- /dev/null +++ b/awx/ui/client/features/output/host-event/host-event.controller.js @@ -0,0 +1,170 @@ +function HostEventsController ( + $scope, + $state, + HostEventService, + hostEvent +) { + $scope.processEventStatus = HostEventService.processEventStatus; + $scope.processResults = processResults; + $scope.isActiveState = isActiveState; + $scope.getActiveHostIndex = getActiveHostIndex; + $scope.closeHostEvent = closeHostEvent; + + function init () { + hostEvent.event_name = hostEvent.event; + $scope.event = _.cloneDeep(hostEvent); + + // grab standard out & standard error if present from the host + // event's 'res' object, for things like Ansible modules. Small + // wrinkle in this implementation is that the stdout/stderr tabs + // should be shown if the `res` object has stdout/stderr keys, even + // if they're a blank string. The presence of these keys is + // potentially significant to a user. + if (_.has(hostEvent.event_data, 'task_action')) { + $scope.module_name = hostEvent.event_data.task_action; + } else if (!_.has(hostEvent.event_data, 'task_action')) { + $scope.module_name = 'No result found'; + } + + if (_.has(hostEvent.event_data, 'res.stdout')) { + if (hostEvent.event_data.res.stdout === '') { + $scope.stdout = ' '; + } else { + $scope.stdout = hostEvent.event_data.res.stdout; + } + } + + if (_.has(hostEvent.event_data, 'res.stderr')) { + if (hostEvent.event_data.res.stderr === '') { + $scope.stderr = ' '; + } else { + $scope.stderr = hostEvent.event_data.res.stderr; + } + } + + if (_.has(hostEvent.event_data, 'res')) { + $scope.json = hostEvent.event_data.res; + } + + if ($scope.module_name === 'debug' && + _.has(hostEvent.event_data, 'res.result.stdout')) { + $scope.stdout = hostEvent.event_data.res.result.stdout; + } + if ($scope.module_name === 'yum' && + _.has(hostEvent.event_data, 'res.results') && + _.isArray(hostEvent.event_data.res.results)) { + const event = hostEvent.event_data.res.results; + $scope.stdout = event[0];// eslint-disable-line prefer-destructuring + } + // instantiate Codemirror + if ($state.current.name === 'jobz.host-event.json') { + try { + if (_.has(hostEvent.event_data, 'res')) { + initCodeMirror( + 'HostEvent-codemirror', + JSON.stringify($scope.json, null, 4), + { name: 'javascript', json: true } + ); + resize(); + } else { + $scope.no_json = true; + } + } catch (err) { + // element with id HostEvent-codemirror is not the view + // controlled by this instance of HostEventController + } + } else if ($state.current.name === 'jobz.host-event.stdout') { + try { + resize(); + } catch (err) { + // element with id HostEvent-codemirror is not the view + // controlled by this instance of HostEventController + } + } else if ($state.current.name === 'jobz.host-event.stderr') { + try { + resize(); + } catch (err) { + // element with id HostEvent-codemirror is not the view + // controlled by this instance of HostEventController + } + } + $('#HostEvent').modal('show'); + $('.modal-content').resizable({ + minHeight: 523, + minWidth: 600 + }); + $('.modal-dialog').draggable({ + cancel: '.HostEvent-view--container' + }); + + function resize () { + if ($state.current.name === 'jobz.host-event.json') { + const editor = $('.CodeMirror')[0].CodeMirror; + const height = $('.modal-dialog').height() - $('.HostEvent-header').height() - $('.HostEvent-details').height() - $('.HostEvent-nav').height() - $('.HostEvent-controls').height() - 120; + editor.setSize('100%', height); + } else if ($state.current.name === 'jobz.host-event.stdout' || $state.current.name === 'jobz.host-event.stderr') { + const height = $('.modal-dialog').height() - $('.HostEvent-header').height() - $('.HostEvent-details').height() - $('.HostEvent-nav').height() - $('.HostEvent-controls').height() - 120; + $('.HostEvent-stdout').width('100%'); + $('.HostEvent-stdout').height(height); + $('.HostEvent-stdoutContainer').height(height); + $('.HostEvent-numberColumnPreload').height(height); + } + } + + $('.modal-dialog').on('resize', resize); + + $('#HostEvent').on('hidden.bs.modal', $scope.closeHostEvent); + } + + function processResults (value) { + if (typeof value === 'object') { + return false; + } + return true; + } + + function initCodeMirror (el, data, mode) { + const container = document.getElementById(el); + const options = {}; + options.lineNumbers = true; + options.mode = mode; + options.readOnly = true; + options.scrollbarStyle = null; + const editor = CodeMirror.fromTextArea(// eslint-disable-line no-undef + container, + options + ); + editor.setSize('100%', 200); + editor.getDoc().setValue(data); + } + + function isActiveState (name) { + return $state.current.name === name; + } + + function getActiveHostIndex () { + function hostResultfilter (obj) { + return obj.id === $scope.event.id; + } + const result = $scope.hostResults.filter(hostResultfilter); + return $scope.hostResults.indexOf(result[0]); + } + + function closeHostEvent () { + // Unbind the listener so it doesn't fire when we close the modal via navigation + $('#HostEvent').off('hidden.bs.modal'); + $('#HostEvent').modal('hide'); + $state.go('jobz'); + } + $scope.init = init; + $scope.init(); +} + +HostEventsController.$inject = [ + '$scope', + '$state', + 'HostEventService', + 'hostEvent', +]; + +module.exports = HostEventsController; diff --git a/awx/ui/client/features/output/host-event/host-event.route.js b/awx/ui/client/features/output/host-event/host-event.route.js new file mode 100644 index 0000000000..105881c778 --- /dev/null +++ b/awx/ui/client/features/output/host-event/host-event.route.js @@ -0,0 +1,72 @@ +const HostEventModalTemplate = require('~features/output/host-event/host-event-modal.partial.html'); +const HostEventCodeMirrorTemplate = require('~features/output/host-event/host-event-codemirror.partial.html'); +const HostEventStdoutTemplate = require('~features/output/host-event/host-event-stdout.partial.html'); +const HostEventStderrTemplate = require('~features/output/host-event/host-event-stderr.partial.html'); + +function exit () { + // close the modal + // using an onExit event to handle cases where the user navs away + // using the url bar / back and not modal "X" + $('#HostEvent').modal('hide'); + // hacky way to handle user browsing away via URL bar + $('.modal-backdrop').remove(); + $('body').removeClass('modal-open'); +} + +function HostEventResolve (HostEventService, $stateParams) { + return HostEventService.getRelatedJobEvents($stateParams.id, $stateParams.type, { + id: $stateParams.eventId + }).then((response) => response.data.results[0]); +} + +HostEventResolve.$inject = [ + 'HostEventService', + '$stateParams', +]; + +const hostEventModal = { + name: 'jobz.host-event', + url: '/host-event/:eventId', + controller: 'HostEventsController', + templateUrl: HostEventModalTemplate, + abstract: false, + ncyBreadcrumb: { + skip: true + }, + resolve: { + hostEvent: HostEventResolve + }, + onExit: exit +}; + +const hostEventJson = { + name: 'jobz.host-event.json', + url: '/json', + controller: 'HostEventsController', + templateUrl: HostEventCodeMirrorTemplate, + ncyBreadcrumb: { + skip: true + }, +}; + +const hostEventStdout = { + name: 'jobz.host-event.stdout', + url: '/stdout', + controller: 'HostEventsController', + templateUrl: HostEventStdoutTemplate, + ncyBreadcrumb: { + skip: true + }, +}; + +const hostEventStderr = { + name: 'jobz.host-event.stderr', + url: '/stderr', + controller: 'HostEventsController', + templateUrl: HostEventStderrTemplate, + ncyBreadcrumb: { + skip: true + }, +}; + +export { hostEventJson, hostEventModal, hostEventStdout, hostEventStderr }; diff --git a/awx/ui/client/features/output/host-event/host-event.service.js b/awx/ui/client/features/output/host-event/host-event.service.js new file mode 100644 index 0000000000..4454bde27b --- /dev/null +++ b/awx/ui/client/features/output/host-event/host-event.service.js @@ -0,0 +1,78 @@ +function HostEventService ( + Rest, + ProcessErrors, + GetBasePath, + $rootScope +) { + this.getUrl = (id, type, params) => { + let url; + if (type === 'playbook') { + url = `${GetBasePath('jobs')}${id}/job_events/?${this.stringifyParams(params)}`; + } else if (type === 'command') { + url = `${GetBasePath('ad_hoc_commands')}${id}/events/?${this.stringifyParams(params)}`; + } + return url; + }; + + // GET events related to a job run + // e.g. + // ?event=playbook_on_stats + // ?parent=206&event__startswith=runner&page_size=200&order=host_name,counter + this.getRelatedJobEvents = (id, type, params) => { + const url = this.getUrl(id, type, params); + Rest.setUrl(url); + return Rest.get() + .then(response => response) + .catch(({ data, status }) => { + ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', + msg: `Call to ${url}. GET returned: ${status}` }); + }); + }; + + this.stringifyParams = params => { + function reduceFunction (result, value, key) { + return `${result}${key}=${value}&`; + } + return _.reduce(params, reduceFunction, ''); + }; + + // Generate a helper class for job_event statuses + // the stack for which status to display is + // unreachable > failed > changed > ok + // uses the API's runner events and convenience properties .failed .changed to determine status. + // see: job_event_callback.py for more filters to support + this.processEventStatus = event => { + const obj = {}; + if (event.event === 'runner_on_unreachable') { + obj.class = 'HostEvent-status--unreachable'; + obj.status = 'unreachable'; + } + // equiv to 'runner_on_error' && 'runner on failed' + if (event.failed) { + obj.class = 'HostEvent-status--failed'; + obj.status = 'failed'; + } + // catch the changed case before ok, because both can be true + if (event.changed) { + obj.class = 'HostEvent-status--changed'; + obj.status = 'changed'; + } + if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok') { + obj.class = 'HostEvent-status--ok'; + obj.status = 'ok'; + } + if (event.event === 'runner_on_skipped') { + obj.class = 'HostEvent-status--skipped'; + obj.status = 'skipped'; + } + return obj; + }; +} + +HostEventService.$inject = [ + 'Rest', + 'ProcessErrors', + 'GetBasePath', + '$rootScope' +]; +export default HostEventService; diff --git a/awx/ui/client/features/output/host-event/index.js b/awx/ui/client/features/output/host-event/index.js new file mode 100644 index 0000000000..f00b0b36b4 --- /dev/null +++ b/awx/ui/client/features/output/host-event/index.js @@ -0,0 +1,26 @@ +import { + hostEventModal, + hostEventJson, + hostEventStdout, + hostEventStderr +} from './host-event.route'; +import controller from './host-event.controller'; +import service from './host-event.service'; + +const MODULE_NAME = 'hostEvents'; + +function hostEventRun ($stateExtender) { + $stateExtender.addState(hostEventModal); + $stateExtender.addState(hostEventJson); + $stateExtender.addState(hostEventStdout); + $stateExtender.addState(hostEventStderr); +} +hostEventRun.$inject = [ + '$stateExtender' +]; + +angular.module(MODULE_NAME, []) + .controller('HostEventsController', controller) + .service('HostEventService', service) + .run(hostEventRun); +export default MODULE_NAME; diff --git a/awx/ui/client/features/output/index.controller.js b/awx/ui/client/features/output/index.controller.js new file mode 100644 index 0000000000..488d994dc2 --- /dev/null +++ b/awx/ui/client/features/output/index.controller.js @@ -0,0 +1,340 @@ +let $compile; +let $q; +let $scope; +let page; +let render; +let resource; +let scroll; +let engine; +let status; + +let vm; + +function JobsIndexController ( + _resource_, + _page_, + _scroll_, + _render_, + _engine_, + _$scope_, + _$compile_, + _$q_, + _status_, +) { + vm = this || {}; + + $compile = _$compile_; + $scope = _$scope_; + $q = _$q_; + resource = _resource_; + + page = _page_; + scroll = _scroll_; + render = _render_; + engine = _engine_; + status = _status_; + + // Development helper(s) + vm.clear = devClear; + + // Expand/collapse + // vm.toggle = toggle; + // vm.expand = expand; + vm.isExpanded = true; + + // Panel + vm.resource = resource; + vm.title = resource.model.get('name'); + + // Stdout Navigation + vm.scroll = { + showBackToTop: false, + home: scrollHome, + end: scrollEnd, + down: scrollPageDown, + up: scrollPageUp + }; + + vm.fullscreen = { + isFullscreen: false + }; + + render.requestAnimationFrame(() => init()); +} + +function init () { + status.init({ + resource, + }); + + page.init({ + resource, + }); + + render.init({ + get: () => resource.model.get(`related.${resource.related}.results`), + compile: html => $compile(html)($scope), + isStreamActive: engine.isActive, + }); + + scroll.init({ + isAtRest: scrollIsAtRest, + previous, + next, + }); + + engine.init({ + page, + scroll, + resource, + onEventFrame (events) { + return shift().then(() => append(events, true)); + }, + onStart () { + status.resetCounts(); + status.setJobStatus('running'); + }, + onStop () { + status.updateStats(); + } + }); + + $scope.$on(resource.ws.events, handleJobEvent); + $scope.$on(resource.ws.status, handleStatusEvent); + + if (!status.isRunning()) { + next(); + } +} + +function handleStatusEvent (scope, data) { + status.pushStatusEvent(data); +} + +function handleJobEvent (scope, data) { + engine.pushJobEvent(data); + + status.pushJobEvent(data); +} + +function devClear (pageMode) { + init(pageMode); + render.clear(); +} + +function next () { + return page.next() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return shift() + .then(() => append(events)) + .then(() => { + if (scroll.isMissing()) { + return next(); + } + + return $q.resolve(); + }); + }); +} + +function previous () { + const initialPosition = scroll.getScrollPosition(); + let postPopHeight; + + return page.previous() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return pop() + .then(() => { + postPopHeight = scroll.getScrollHeight(); + + return prepend(events); + }) + .then(() => { + const currentHeight = scroll.getScrollHeight(); + scroll.setScrollPosition(currentHeight - postPopHeight + initialPosition); + }); + }); +} + +function append (events, eng) { + return render.append(events) + .then(count => { + page.updateLineCount(count, eng); + }); +} + +function prepend (events) { + return render.prepend(events) + .then(count => { + page.updateLineCount(count); + }); +} + +function pop () { + if (!page.isOverCapacity()) { + return $q.resolve(); + } + + const lines = page.trim(); + + return render.pop(lines); +} + +function shift () { + if (!page.isOverCapacity()) { + return $q.resolve(); + } + + const lines = page.trim(true); + + return render.shift(lines); +} + +function scrollHome () { + if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return page.first() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return render.clear() + .then(() => prepend(events)) + .then(() => { + scroll.resetScrollPosition(); + scroll.resume(); + }) + .then(() => { + if (scroll.isMissing()) { + return next(); + } + + return $q.resolve(); + }); + }); +} + +function scrollEnd () { + if (engine.isActive()) { + if (engine.isTransitioning()) { + return $q.resolve(); + } + + if (engine.isPaused()) { + engine.resume(); + } else { + engine.pause(); + } + + return $q.resolve(); + } else if (scroll.isPaused()) { + return $q.resolve(); + } + + scroll.pause(); + + return page.last() + .then(events => { + if (!events) { + return $q.resolve(); + } + + return render.clear() + .then(() => append(events)) + .then(() => { + scroll.setScrollPosition(scroll.getScrollHeight()); + scroll.resume(); + }); + }); +} + +function scrollPageUp () { + if (scroll.isPaused()) { + return; + } + + scroll.pageUp(); +} + +function scrollPageDown () { + if (scroll.isPaused()) { + return; + } + + scroll.pageDown(); +} + +function scrollIsAtRest (isAtRest) { + vm.scroll.showBackToTop = !isAtRest; +} + +// function expand () { +// vm.toggle(parent, true); +// } + +// function showHostDetails (id) { +// jobEvent.request('get', id) +// .then(() => { +// const title = jobEvent.get('host_name'); + +// vm.host = { +// menu: true, +// stdout: jobEvent.get('stdout') +// }; + +// $scope.jobs.modal.show(title); +// }); +// } + +// function toggle (uuid, menu) { +// const lines = $(`.child-of-${uuid}`); +// let icon = $(`#${uuid} .at-Stdout-toggle > i`); + +// if (menu || record[uuid].level === 1) { +// vm.isExpanded = !vm.isExpanded; +// } + +// if (record[uuid].children) { +// icon = icon.add($(`#${record[uuid].children.join(', #')}`) +// .find('.at-Stdout-toggle > i')); +// } + +// if (icon.hasClass('fa-angle-down')) { +// icon.addClass('fa-angle-right'); +// icon.removeClass('fa-angle-down'); + +// lines.addClass('hidden'); +// } else { +// icon.addClass('fa-angle-down'); +// icon.removeClass('fa-angle-right'); + +// lines.removeClass('hidden'); +// } +// } + +JobsIndexController.$inject = [ + 'resource', + 'JobPageService', + 'JobScrollService', + 'JobRenderService', + 'JobEventEngine', + '$scope', + '$compile', + '$q', + 'JobStatusService', +]; + +module.exports = JobsIndexController; diff --git a/awx/ui/client/features/output/index.js b/awx/ui/client/features/output/index.js new file mode 100644 index 0000000000..3aaadc2553 --- /dev/null +++ b/awx/ui/client/features/output/index.js @@ -0,0 +1,229 @@ +import atLibModels from '~models'; +import atLibComponents from '~components'; + +import Strings from '~features/output/jobs.strings'; +import Controller from '~features/output/index.controller'; +import PageService from '~features/output/page.service'; +import RenderService from '~features/output/render.service'; +import ScrollService from '~features/output/scroll.service'; +import EngineService from '~features/output/engine.service'; +import StatusService from '~features/output/status.service'; + +import DetailsDirective from '~features/output/details.directive'; +import SearchDirective from '~features/output/search.directive'; +import StatsDirective from '~features/output/stats.directive'; +import HostEvent from './host-event/index'; + +const Template = require('~features/output/index.view.html'); + +const MODULE_NAME = 'at.features.output'; + +const PAGE_CACHE = true; +const PAGE_LIMIT = 5; +const PAGE_SIZE = 50; +const WS_PREFIX = 'ws'; + +function resolveResource ( + Job, + ProjectUpdate, + AdHocCommand, + SystemJob, + WorkflowJob, + InventoryUpdate, + $stateParams, + qs, + Wait +) { + const { id, type, job_event_search } = $stateParams; // eslint-disable-line camelcase + const { name, key } = getWebSocketResource(type); + + let Resource; + let related = 'events'; + + switch (type) { + case 'project': + Resource = ProjectUpdate; + break; + case 'playbook': + Resource = Job; + related = 'job_events'; + break; + case 'command': + Resource = AdHocCommand; + break; + case 'system': + Resource = SystemJob; + break; + case 'inventory': + Resource = InventoryUpdate; + break; + // case 'workflow': + // todo: integrate workflow chart components into this view + // break; + default: + // Redirect + return null; + } + + const params = { page_size: PAGE_SIZE, order_by: 'start_line' }; + const config = { pageCache: PAGE_CACHE, pageLimit: PAGE_LIMIT, params }; + + if (job_event_search) { // eslint-disable-line camelcase + const queryParams = qs.encodeQuerysetObject(qs.decodeArr(job_event_search)); + + Object.assign(config.params, queryParams); + } + + Wait('start'); + return new Resource(['get', 'options'], [id, id]) + .then(model => { + const promises = [model.getStats()]; + + if (model.has('related.labels')) { + promises.push(model.extend('get', 'labels')); + } + + promises.push(model.extend('get', related, config)); + + return Promise.all(promises); + }) + .then(([stats, model]) => ({ + id, + type, + stats, + model, + related, + ws: { + events: `${WS_PREFIX}-${key}-${id}`, + status: `${WS_PREFIX}-${name}`, + }, + page: { + cache: PAGE_CACHE, + size: PAGE_SIZE, + pageLimit: PAGE_LIMIT + } + })) + .catch(({ data, status }) => qs.error(data, status)) + .finally(() => Wait('stop')); +} + +function resolveWebSocketConnection ($stateParams, SocketService) { + const { type, id } = $stateParams; + const { name, key } = getWebSocketResource(type); + + const state = { + data: { + socket: { + groups: { + [name]: ['status_changed', 'summary'], + [key]: [] + } + } + } + }; + + return SocketService.addStateResolve(state, id); +} + +function resolveBreadcrumb (strings) { + return { + label: strings.get('state.TITLE') + }; +} + +function getWebSocketResource (type) { + let name; + let key; + + switch (type) { + case 'system': + name = 'jobs'; + key = 'system_job_events'; + break; + case 'project': + name = 'jobs'; + key = 'project_update_events'; + break; + case 'command': + name = 'jobs'; + key = 'ad_hoc_command_events'; + break; + case 'inventory': + name = 'jobs'; + key = 'inventory_update_events'; + break; + case 'playbook': + name = 'jobs'; + key = 'job_events'; + break; + default: + throw new Error('Unsupported WebSocket type'); + } + + return { name, key }; +} + +function JobsRun ($stateRegistry) { + const state = { + name: 'jobz', + url: '/jobz/:type/:id?job_event_search', + route: '/jobz/:type/:id?job_event_search', + data: { + activityStream: true, + activityStreamTarget: 'jobs' + }, + views: { + '@': { + templateUrl: Template, + controller: Controller, + controllerAs: 'vm' + } + }, + resolve: { + resource: [ + 'JobModel', + 'ProjectUpdateModel', + 'AdHocCommandModel', + 'SystemJobModel', + 'WorkflowJobModel', + 'InventoryUpdateModel', + '$stateParams', + 'QuerySet', + 'Wait', + resolveResource + ], + ncyBreadcrumb: [ + 'JobStrings', + resolveBreadcrumb + ], + webSocketConnection: [ + '$stateParams', + 'SocketService', + resolveWebSocketConnection + ], + }, + }; + + $stateRegistry.register(state); +} + +JobsRun.$inject = ['$stateRegistry']; + +angular + .module(MODULE_NAME, [ + atLibModels, + atLibComponents, + HostEvent + ]) + .service('JobStrings', Strings) + .service('JobPageService', PageService) + .service('JobScrollService', ScrollService) + .service('JobRenderService', RenderService) + .service('JobEventEngine', EngineService) + .service('JobStatusService', StatusService) + .directive('atJobDetails', DetailsDirective) + .directive('atJobSearch', SearchDirective) + .directive('atJobStats', StatsDirective) + .run(JobsRun); + +export default MODULE_NAME; diff --git a/awx/ui/client/features/output/index.view.html b/awx/ui/client/features/output/index.view.html new file mode 100644 index 0000000000..b3bb6e95bc --- /dev/null +++ b/awx/ui/client/features/output/index.view.html @@ -0,0 +1,64 @@ +
+
+
+ + + +
+ +
+ +
{{ vm.title }}
+ + + + +
+
+ +
+ +
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ +
+                
+                    
+                        
+                            
+                            
+                            
+                        
+                    
+                    
+                
 
+
+ +
+
+

+

Back to Top

+
+ +
+
+
+
+
diff --git a/awx/ui/client/features/output/jobs.strings.js b/awx/ui/client/features/output/jobs.strings.js new file mode 100644 index 0000000000..f1f3ace11d --- /dev/null +++ b/awx/ui/client/features/output/jobs.strings.js @@ -0,0 +1,43 @@ +function JobsStrings (BaseString) { + BaseString.call(this, 'jobs'); + + const { t } = this; + const ns = this.jobs; + + ns.state = { + TITLE: t.s('JOBZ') + }; + + ns.warnings = { + CANCEL_ACTION: t.s('PROCEED'), + CANCEL_BODY: t.s('Are you sure you want to cancel this job?'), + CANCEL_HEADER: t.s('Cancel Job'), + DELETE_BODY: t.s('Are you sure you want to delete this job?'), + DELETE_HEADER: t.s('Delete Job'), + }; + + ns.status = { + RUNNING: t.s('The host status bar will update when the job is complete.'), + UNAVAILABLE: t.s('Host status information for this job is unavailable.'), + }; + + ns.resourceTooltips = { + USER: t.s('View the User'), + SCHEDULE: t.s('View the Schedule'), + INVENTORY: t.s('View the Inventory'), + CREDENTIAL: t.s('View the Credential'), + JOB_TEMPLATE: t.s('View the Job Template'), + SOURCE_WORKFLOW_JOB: t.s('View the source Workflow Job'), + PROJECT: t.s('View the Project'), + PROJECT_UPDATE: t.s('View Project checkout results') + }; + + ns.expandCollapse = { + EXPAND: t.s('Expand Output'), + COLLAPSE: t.s('Collapse Output') + }; +} + +JobsStrings.$inject = ['BaseStringService']; + +export default JobsStrings; diff --git a/awx/ui/client/features/output/page.service.js b/awx/ui/client/features/output/page.service.js new file mode 100644 index 0000000000..5f19fe921a --- /dev/null +++ b/awx/ui/client/features/output/page.service.js @@ -0,0 +1,298 @@ +function JobPageService ($q) { + this.init = ({ resource }) => { + this.resource = resource; + + this.page = { + limit: this.resource.page.pageLimit, + size: this.resource.page.size, + cache: [], + state: { + count: 0, + current: 0, + first: 0, + last: 0 + } + }; + + this.bookmark = { + pending: false, + set: false, + cache: [], + state: { + count: 0, + first: 0, + last: 0, + current: 0 + } + }; + + this.result = { + limit: this.page.limit * this.page.size, + count: 0 + }; + + this.buffer = { + count: 0 + }; + }; + + this.addPage = (number, events, push, reference) => { + const page = { number, events, lines: 0 }; + reference = reference || this.getActiveReference(); + + if (push) { + reference.cache.push(page); + reference.state.last = page.number; + reference.state.first = reference.cache[0].number; + } else { + reference.cache.unshift(page); + reference.state.first = page.number; + reference.state.last = reference.cache[reference.cache.length - 1].number; + } + + reference.state.current = page.number; + reference.state.count++; + }; + + this.addToBuffer = event => { + const reference = this.getReference(); + const index = reference.cache.length - 1; + let pageAdded = false; + + if (this.result.count % this.page.size === 0) { + this.addPage(reference.state.current + 1, [event], true, reference); + + if (this.isBookmarkPending()) { + this.setBookmark(); + } + + this.trimBuffer(); + + pageAdded = true; + } else { + reference.cache[index].events.push(event); + } + + this.buffer.count++; + this.result.count++; + + return pageAdded; + }; + + this.trimBuffer = () => { + const reference = this.getReference(); + const diff = reference.cache.length - this.page.limit; + + if (diff <= 0) { + return; + } + + for (let i = 0; i < diff; i++) { + if (reference.cache[i].events) { + this.buffer.count -= reference.cache[i].events.length; + reference.cache[i].events.splice(0, reference.cache[i].events.length); + } + } + }; + + this.isBufferFull = () => { + if (this.buffer.count === 2) { + return true; + } + + return false; + }; + + this.emptyBuffer = () => { + const reference = this.getReference(); + let data = []; + + for (let i = 0; i < reference.cache.length; i++) { + const count = reference.cache[i].events.length; + + if (count > 0) { + this.buffer.count -= count; + data = data.concat(reference.cache[i].events.splice(0, count)); + } + } + + return data; + }; + + this.emptyCache = number => { + const reference = this.getActiveReference(); + + number = number || reference.state.current; + + reference.state.first = number; + reference.state.last = number; + reference.state.current = number; + reference.cache.splice(0, reference.cache.length); + }; + + this.isOverCapacity = () => { + const reference = this.getActiveReference(); + + return (reference.cache.length - this.page.limit) > 0; + }; + + this.trim = left => { + const reference = this.getActiveReference(); + const excess = reference.cache.length - this.page.limit; + + let ejected; + + if (left) { + ejected = reference.cache.splice(0, excess); + reference.state.first = reference.cache[0].number; + } else { + ejected = reference.cache.splice(-excess); + reference.state.last = reference.cache[reference.cache.length - 1].number; + } + + return ejected.reduce((total, page) => total + page.lines, 0); + }; + + this.isPageBookmarked = number => number >= this.page.bookmark.first && + number <= this.page.bookmark.last; + + this.updateLineCount = (lines, engine) => { + let reference; + + if (engine) { + reference = this.getReference(); + } else { + reference = this.getActiveReference(); + } + + const index = reference.cache.findIndex(item => item.number === reference.state.current); + + reference.cache[index].lines += lines; + }; + + this.isBookmarkPending = () => this.bookmark.pending; + this.isBookmarkSet = () => this.bookmark.set; + + this.setBookmark = () => { + if (this.isBookmarkSet()) { + return; + } + + if (!this.isBookmarkPending()) { + this.bookmark.pending = true; + + return; + } + + this.bookmark.state.first = this.page.state.first; + this.bookmark.state.last = this.page.state.last - 1; + this.bookmark.state.current = this.page.state.current - 1; + this.bookmark.cache = JSON.parse(JSON.stringify(this.page.cache)); + this.bookmark.set = true; + this.bookmark.pending = false; + }; + + this.removeBookmark = () => { + this.bookmark.set = false; + this.bookmark.pending = false; + this.bookmark.cache.splice(0, this.bookmark.cache.length); + this.bookmark.state.first = 0; + this.bookmark.state.last = 0; + this.bookmark.state.current = 0; + }; + + this.next = () => { + const reference = this.getActiveReference(); + const config = this.buildRequestConfig(reference.state.last + 1); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.addPage(data.page, [], true); + + return data.results; + }); + }; + + this.previous = () => { + const reference = this.getActiveReference(); + const config = this.buildRequestConfig(reference.state.first - 1); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.addPage(data.page, [], false); + + return data.results; + }); + }; + + this.last = () => { + const config = this.buildRequestConfig('last'); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.emptyCache(data.page); + this.addPage(data.page, [], true); + + return data.results; + }); + }; + + this.first = () => { + const config = this.buildRequestConfig('first'); + + return this.resource.model.goToPage(config) + .then(data => { + if (!data || !data.results) { + return $q.resolve(); + } + + this.emptyCache(data.page); + this.addPage(data.page, [], false); + + return data.results; + }); + }; + + this.buildRequestConfig = number => ({ + page: number, + related: this.resource.related, + params: { + order_by: 'start_line' + } + }); + + this.getActiveReference = () => (this.isBookmarkSet() ? + this.getReference(true) : this.getReference()); + + this.getReference = (bookmark) => { + if (bookmark) { + return { + bookmark: true, + cache: this.bookmark.cache, + state: this.bookmark.state + }; + } + + return { + bookmark: false, + cache: this.page.cache, + state: this.page.state + }; + }; +} + +JobPageService.$inject = ['$q']; + +export default JobPageService; diff --git a/awx/ui/client/features/output/render.service.js b/awx/ui/client/features/output/render.service.js new file mode 100644 index 0000000000..aa86913133 --- /dev/null +++ b/awx/ui/client/features/output/render.service.js @@ -0,0 +1,288 @@ +import Ansi from 'ansi-to-html'; +import Entities from 'html-entities'; + +const ELEMENT_TBODY = '#atStdoutResultTable'; +const EVENT_START_TASK = 'playbook_on_task_start'; +const EVENT_START_PLAY = 'playbook_on_play_start'; +const EVENT_STATS_PLAY = 'playbook_on_stats'; + +const EVENT_GROUPS = [ + EVENT_START_TASK, + EVENT_START_PLAY +]; + +const TIME_EVENTS = [ + EVENT_START_TASK, + EVENT_START_PLAY, + EVENT_STATS_PLAY +]; + +const ansi = new Ansi(); +const entities = new Entities.AllHtmlEntities(); + +// https://github.com/chalk/ansi-regex +const pattern = [ + '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)', + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))' +].join('|'); + +const re = new RegExp(pattern); +const hasAnsi = input => re.test(input); + +function JobRenderService ($q, $sce, $window) { + this.init = ({ compile, apply, isStreamActive }) => { + this.parent = null; + this.record = {}; + this.el = $(ELEMENT_TBODY); + this.hooks = { isStreamActive, compile, apply }; + }; + + this.sortByLineNumber = (a, b) => { + if (a.start_line > b.start_line) { + return 1; + } + + if (a.start_line < b.start_line) { + return -1; + } + + return 0; + }; + + this.transformEventGroup = events => { + let lines = 0; + let html = ''; + + events.sort(this.sortByLineNumber); + + events.forEach(event => { + const line = this.transformEvent(event); + + html += line.html; + lines += line.count; + }); + + return { html, lines }; + }; + + this.transformEvent = event => { + if (!event || !event.stdout) { + return { html: '', count: 0 }; + } + + const stdout = this.sanitize(event.stdout); + const lines = stdout.split('\r\n'); + + let count = lines.length; + let ln = event.start_line; + + const current = this.createRecord(ln, lines, event); + + const html = lines.reduce((concat, line, i) => { + ln++; + + const isLastLine = i === lines.length - 1; + + let row = this.createRow(current, ln, line); + + if (current && current.isTruncated && isLastLine) { + row += this.createRow(current); + count++; + } + + return `${concat}${row}`; + }, ''); + + return { html, count }; + }; + + this.createRecord = (ln, lines, event) => { + if (!event.uuid) { + return null; + } + + const info = { + id: event.id, + line: ln + 1, + uuid: event.uuid, + level: event.event_level, + start: event.start_line, + end: event.end_line, + isTruncated: (event.end_line - event.start_line) > lines.length, + isHost: typeof event.host === 'number' + }; + + if (event.parent_uuid) { + info.parents = this.getParentEvents(event.parent_uuid); + } + + if (info.isTruncated) { + info.truncatedAt = event.start_line + lines.length; + } + + if (EVENT_GROUPS.includes(event.event)) { + info.isParent = true; + + if (event.event_level === 1) { + this.parent = event.uuid; + } + + if (event.parent_uuid) { + if (this.record[event.parent_uuid]) { + if (this.record[event.parent_uuid].children && + !this.record[event.parent_uuid].children.includes(event.uuid)) { + this.record[event.parent_uuid].children.push(event.uuid); + } else { + this.record[event.parent_uuid].children = [event.uuid]; + } + } + } + } + + if (TIME_EVENTS.includes(event.event)) { + info.time = this.getTimestamp(event.created); + info.line++; + } + + this.record[event.uuid] = info; + + return info; + }; + + this.createRow = (current, ln, content) => { + let id = ''; + let timestamp = ''; + let tdToggle = ''; + let tdEvent = ''; + let classList = ''; + + content = content || ''; + + if (hasAnsi(content)) { + content = ansi.toHtml(content); + } + + if (current) { + if (!this.hooks.isStreamActive() && current.isParent && current.line === ln) { + id = current.uuid; + tdToggle = ``; + } + + if (current.isHost) { + tdEvent = `${content}`; + } + + if (current.time && current.line === ln) { + timestamp = `${current.time}`; + } + + if (current.parents) { + classList = current.parents.reduce((list, uuid) => `${list} child-of-${uuid}`, ''); + } + } + + if (!tdEvent) { + tdEvent = `${content}`; + } + + if (!tdToggle) { + tdToggle = ''; + } + + if (!ln) { + ln = '...'; + } + + return ` + + ${tdToggle} + ${ln} + ${tdEvent} + ${timestamp} + `; + }; + + this.getTimestamp = created => { + const date = new Date(created); + const hour = date.getHours() < 10 ? `0${date.getHours()}` : date.getHours(); + const minute = date.getMinutes() < 10 ? `0${date.getMinutes()}` : date.getMinutes(); + const second = date.getSeconds() < 10 ? `0${date.getSeconds()}` : date.getSeconds(); + + return `${hour}:${minute}:${second}`; + }; + + this.getParentEvents = (uuid, list) => { + list = list || []; + + if (this.record[uuid]) { + list.push(uuid); + + if (this.record[uuid].parents) { + list = list.concat(this.record[uuid].parents); + } + } + + return list; + }; + + this.getEvents = () => this.hooks.get(); + + this.insert = (events, insert) => { + const result = this.transformEventGroup(events); + const html = this.trustHtml(result.html); + + return this.requestAnimationFrame(() => insert(html)) + .then(() => this.compile(html)) + .then(() => result.lines); + }; + + this.remove = elements => this.requestAnimationFrame(() => { + elements.remove(); + }); + + this.requestAnimationFrame = fn => $q(resolve => { + $window.requestAnimationFrame(() => { + if (fn) { + fn(); + } + + return resolve(); + }); + }); + + this.compile = html => { + html = $(this.el); + this.hooks.compile(html); + + return this.requestAnimationFrame(); + }; + + this.clear = () => { + const elements = this.el.children(); + return this.remove(elements); + }; + + this.shift = lines => { + const elements = this.el.children().slice(0, lines); + + return this.remove(elements); + }; + + this.pop = lines => { + const elements = this.el.children().slice(-lines); + + return this.remove(elements); + }; + + this.prepend = events => this.insert(events, html => this.el.prepend(html)); + + this.append = events => this.insert(events, html => this.el.append(html)); + + this.trustHtml = html => $sce.getTrustedHtml($sce.trustAsHtml(html)); + + this.sanitize = html => entities.encode(html); +} + +JobRenderService.$inject = ['$q', '$sce', '$window']; + +export default JobRenderService; diff --git a/awx/ui/client/features/output/scroll.service.js b/awx/ui/client/features/output/scroll.service.js new file mode 100644 index 0000000000..a568813ddc --- /dev/null +++ b/awx/ui/client/features/output/scroll.service.js @@ -0,0 +1,167 @@ +const ELEMENT_CONTAINER = '.at-Stdout-container'; +const ELEMENT_TBODY = '#atStdoutResultTable'; +const DELAY = 100; +const THRESHOLD = 0.1; + +function JobScrollService ($q, $timeout) { + this.init = (hooks) => { + this.el = $(ELEMENT_CONTAINER); + this.timer = null; + + this.position = { + previous: 0, + current: 0 + }; + + this.hooks = { + isAtRest: hooks.isAtRest, + next: hooks.next, + previous: hooks.previous + }; + + this.state = { + locked: false, + paused: false, + top: true + }; + + this.el.scroll(this.listen); + }; + + this.listen = () => { + if (this.isPaused()) { + return; + } + + if (this.timer) { + $timeout.cancel(this.timer); + } + + this.timer = $timeout(this.register, DELAY); + }; + + this.register = () => { + this.pause(); + + const current = this.getScrollPosition(); + const downward = current > this.position.previous; + + let promise; + + if (downward && this.isBeyondThreshold(downward, current)) { + promise = this.hooks.next; + } else if (!downward && this.isBeyondThreshold(downward, current)) { + promise = this.hooks.previous; + } + + if (!promise) { + this.setScrollPosition(current); + this.isAtRest(); + this.resume(); + + return $q.resolve(); + } + + return promise() + .then(() => { + this.setScrollPosition(this.getScrollPosition()); + this.isAtRest(); + this.resume(); + }); + }; + + this.isBeyondThreshold = (downward, current) => { + const height = this.getScrollHeight(); + + if (downward) { + current += this.getViewableHeight(); + + if (current >= height || ((height - current) / height) < THRESHOLD) { + return true; + } + } else if (current <= 0 || (current / height) < THRESHOLD) { + return true; + } + + return false; + }; + + this.pageUp = () => { + if (this.isPaused()) { + return; + } + + const top = this.getScrollPosition(); + const height = this.getViewableHeight(); + + this.setScrollPosition(top - height); + }; + + this.pageDown = () => { + if (this.isPaused()) { + return; + } + + const top = this.getScrollPosition(); + const height = this.getViewableHeight(); + + this.setScrollPosition(top + height); + }; + + this.getScrollHeight = () => this.el[0].scrollHeight; + this.getViewableHeight = () => this.el[0].offsetHeight; + this.getScrollPosition = () => this.el[0].scrollTop; + + this.setScrollPosition = position => { + this.position.previous = this.position.current; + this.position.current = position; + this.el[0].scrollTop = position; + this.isAtRest(); + }; + + this.resetScrollPosition = () => { + this.position.previous = 0; + this.position.current = 0; + this.el[0].scrollTop = 0; + this.isAtRest(); + }; + + this.scrollToBottom = () => { + this.setScrollPosition(this.getScrollHeight()); + }; + + this.isAtRest = () => { + if (this.position.current === 0 && !this.state.top) { + this.state.top = true; + this.hooks.isAtRest(true); + } else if (this.position.current > 0 && this.state.top) { + this.state.top = false; + this.hooks.isAtRest(false); + } + }; + + this.resume = () => { + this.state.paused = false; + }; + + this.pause = () => { + this.state.paused = true; + }; + + this.isPaused = () => this.state.paused; + + this.lock = () => { + this.state.locked = true; + }; + + this.unlock = () => { + this.state.locked = false; + }; + + this.isLocked = () => this.state.locked; + this.isMissing = () => $(ELEMENT_TBODY)[0].clientHeight < this.getViewableHeight(); +} + +JobScrollService.$inject = ['$q', '$timeout']; + +export default JobScrollService; diff --git a/awx/ui/client/features/output/search.directive.js b/awx/ui/client/features/output/search.directive.js new file mode 100644 index 0000000000..0a688f92bb --- /dev/null +++ b/awx/ui/client/features/output/search.directive.js @@ -0,0 +1,129 @@ +const templateUrl = require('~features/output/search.partial.html'); + +const searchReloadOptions = { reload: true, inherit: false }; +const searchKeyExamples = ['id:>1', 'task:set', 'created:>=2000-01-01']; +const searchKeyFields = ['changed', 'failed', 'host_name', 'stdout', 'task', 'role', 'playbook', 'play']; + +const PLACEHOLDER_RUNNING = 'CANNOT SEARCH RUNNING JOB'; +const PLACEHOLDER_DEFAULT = 'SEARCH'; + +let $state; +let status; +let qs; + +let vm; + +function toggleSearchKey () { + vm.key = !vm.key; +} + +function getCurrentQueryset () { + const { job_event_search } = $state.params; // eslint-disable-line camelcase + + return qs.decodeArr(job_event_search); +} + +function getSearchTags (queryset) { + return qs.createSearchTagsFromQueryset(queryset) + .filter(tag => !tag.startsWith('event')) + .filter(tag => !tag.startsWith('-event')) + .filter(tag => !tag.startsWith('page_size')) + .filter(tag => !tag.startsWith('order_by')); +} + +function removeSearchTag (index) { + const searchTerm = vm.tags[index]; + + const currentQueryset = getCurrentQueryset(); + const modifiedQueryset = qs.removeTermsFromQueryset(currentQueryset, searchTerm); + + vm.tags = getSearchTags(modifiedQueryset); + vm.disabled = true; + + $state.params.job_event_search = qs.encodeArr(modifiedQueryset); + $state.transitionTo($state.current, $state.params, searchReloadOptions); +} + +function submitSearch () { + const searchInputQueryset = qs.getSearchInputQueryset(vm.value); + + const currentQueryset = getCurrentQueryset(); + const modifiedQueryset = qs.mergeQueryset(currentQueryset, searchInputQueryset); + + vm.tags = getSearchTags(modifiedQueryset); + vm.disabled = true; + + $state.params.job_event_search = qs.encodeArr(modifiedQueryset); + $state.transitionTo($state.current, $state.params, searchReloadOptions); +} + +function clearSearch () { + vm.tags = []; + vm.disabled = true; + + $state.params.job_event_search = ''; + $state.transitionTo($state.current, $state.params, searchReloadOptions); +} + +function atJobSearchLink (scope, el, attrs, controllers) { + const [atJobSearchController] = controllers; + + atJobSearchController.init(scope); +} + +function AtJobSearchController (_$state_, _status_, _qs_) { + $state = _$state_; + status = _status_; + qs = _qs_; + + vm = this || {}; + + vm.value = ''; + vm.key = false; + vm.rejected = false; + vm.disabled = true; + vm.tags = getSearchTags(getCurrentQueryset()); + + vm.clearSearch = clearSearch; + vm.searchKeyExamples = searchKeyExamples; + vm.searchKeyFields = searchKeyFields; + vm.toggleSearchKey = toggleSearchKey; + vm.removeSearchTag = removeSearchTag; + vm.submitSearch = submitSearch; + + vm.init = scope => { + vm.examples = scope.examples || searchKeyExamples; + vm.fields = scope.fields || searchKeyFields; + vm.placeholder = PLACEHOLDER_DEFAULT; + vm.relatedFields = scope.relatedFields || []; + + scope.$watch(status.isRunning, value => { + vm.disabled = value; + vm.placeholder = value ? PLACEHOLDER_RUNNING : PLACEHOLDER_DEFAULT; + }); + }; +} + +AtJobSearchController.$inject = [ + '$state', + 'JobStatusService', + 'QuerySet', +]; + +function atJobSearch () { + return { + templateUrl, + restrict: 'E', + require: ['atJobSearch'], + controllerAs: 'vm', + link: atJobSearchLink, + controller: AtJobSearchController, + scope: { + examples: '=', + fields: '=', + relatedFields: '=', + }, + }; +} + +export default atJobSearch; diff --git a/awx/ui/client/features/output/search.partial.html b/awx/ui/client/features/output/search.partial.html new file mode 100644 index 0000000000..d7acedc3d4 --- /dev/null +++ b/awx/ui/client/features/output/search.partial.html @@ -0,0 +1,61 @@ + +
+
+ + + + + + +
+
+ +
+
+
{{ tag }}
+
+ +
+
+ +
+ +
+
+
+
+
EXAMPLES:
+ +
+
+
+ FIELDS: + {{ field }}, +
+
+ ADDITIONAL INFORMATION: + For additional information on advanced search search syntax please see the Ansible Tower + documentation. +
+
+
diff --git a/awx/ui/client/features/output/stats.directive.js b/awx/ui/client/features/output/stats.directive.js new file mode 100644 index 0000000000..789fc29de6 --- /dev/null +++ b/awx/ui/client/features/output/stats.directive.js @@ -0,0 +1,91 @@ +const templateUrl = require('~features/output/stats.partial.html'); + +let status; +let strings; + +function createStatsBarTooltip (key, count) { + const label = `${key}`; + const badge = `${count}`; + + return `${label}${badge}`; +} + +function atJobStatsLink (scope, el, attrs, controllers) { + const [atJobStatsController] = controllers; + + atJobStatsController.init(scope); +} + +function AtJobStatsController (_strings_, _status_) { + status = _status_; + strings = _strings_; + + const vm = this || {}; + + vm.tooltips = { + running: strings.get('status.RUNNING'), + unavailable: strings.get('status.UNAVAILABLE'), + }; + + vm.init = scope => { + const { resource } = scope; + + vm.fullscreen = scope.fullscreen; + + vm.download = resource.model.get('related.stdout'); + + vm.toggleStdoutFullscreenTooltip = strings.get('expandCollapse.EXPAND'); + + vm.setHostStatusCounts(status.getHostStatusCounts()); + + scope.$watch(status.getPlayCount, value => { vm.plays = value; }); + scope.$watch(status.getTaskCount, value => { vm.tasks = value; }); + scope.$watch(status.getElapsed, value => { vm.elapsed = value; }); + scope.$watch(status.getHostCount, value => { vm.hosts = value; }); + scope.$watch(status.isRunning, value => { vm.running = value; }); + + scope.$watchCollection(status.getHostStatusCounts, vm.setHostStatusCounts); + }; + + vm.setHostStatusCounts = counts => { + Object.keys(counts).forEach(key => { + const count = counts[key]; + const statusBarElement = $(`.HostStatusBar-${key}`); + + statusBarElement.css('flex', `${count} 0 auto`); + + vm.tooltips[key] = createStatsBarTooltip(key, count); + }); + + vm.statsAreAvailable = Boolean(status.getStatsEvent()); + }; + + vm.toggleFullscreen = () => { + vm.fullscreen.isFullscreen = !vm.fullscreen.isFullscreen; + vm.toggleStdoutFullscreenTooltip = vm.fullscreen.isFullscreen ? + strings.get('expandCollapse.COLLAPSE') : + strings.get('expandCollapse.EXPAND'); + }; +} + +function atJobStats () { + return { + templateUrl, + restrict: 'E', + require: ['atJobStats'], + controllerAs: 'vm', + link: atJobStatsLink, + controller: [ + 'JobStrings', + 'JobStatusService', + '$scope', + AtJobStatsController + ], + scope: { + resource: '=', + fullscreen: '=' + } + }; +} + +export default atJobStats; diff --git a/awx/ui/client/features/output/stats.partial.html b/awx/ui/client/features/output/stats.partial.html new file mode 100644 index 0000000000..70d980ed33 --- /dev/null +++ b/awx/ui/client/features/output/stats.partial.html @@ -0,0 +1,81 @@ + +
+ plays + ... + {{ vm.plays }} + + tasks + ... + {{ vm.tasks }} + + hosts + ... + {{ vm.hosts }} + + elapsed + ... + + {{ vm.elapsed * 1000 | duration: "hh:mm:ss" }} + + + + + + + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/awx/ui/client/features/output/status.service.js b/awx/ui/client/features/output/status.service.js new file mode 100644 index 0000000000..638d2ff399 --- /dev/null +++ b/awx/ui/client/features/output/status.service.js @@ -0,0 +1,171 @@ +const JOB_START = 'playbook_on_start'; +const JOB_END = 'playbook_on_stats'; +const PLAY_START = 'playbook_on_play_start'; +const TASK_START = 'playbook_on_task_start'; + +const HOST_STATUS_KEYS = ['dark', 'failures', 'changed', 'ok', 'skipped']; +const FINISHED = ['successful', 'failed', 'error']; + +let moment; + +function JobStatusService (_moment_) { + moment = _moment_; + + this.init = ({ resource }) => { + this.counter = -1; + + this.created = resource.model.get('created'); + this.job = resource.model.get('id'); + this.jobType = resource.model.get('type'); + this.project = resource.model.get('project'); + this.elapsed = resource.model.get('elapsed'); + this.started = resource.model.get('started'); + this.finished = resource.model.get('finished'); + this.jobStatus = resource.model.get('status'); + this.projectStatus = resource.model.get('summary_fields.project_update.status'); + this.projectUpdateId = resource.model.get('summary_fields.project_update.id'); + + this.latestTime = null; + this.playCount = null; + this.taskCount = null; + this.hostCount = null; + this.active = false; + this.hostStatusCounts = {}; + + this.statsEvent = resource.stats; + this.updateStats(); + }; + + this.pushStatusEvent = data => { + const isJobEvent = (this.job === data.unified_job_id); + const isProjectEvent = (this.project && (this.project === data.project_id)); + + if (isJobEvent) { + this.setJobStatus(data.status); + } else if (isProjectEvent) { + this.setProjectStatus(data.status); + this.setProjectUpdateId(data.unified_job_id); + } + }; + + this.pushJobEvent = data => { + const isLatest = ((!this.counter) || (data.counter > this.counter)); + + if (!this.active && !(data.event === JOB_END)) { + this.active = true; + this.setJobStatus('running'); + } + + if (isLatest) { + this.counter = data.counter; + this.latestTime = data.created; + this.elapsed = moment(data.created).diff(this.created, 'seconds'); + } + + if (data.event === JOB_START) { + this.started = this.started || data.created; + } + + if (data.event === PLAY_START) { + this.playCount++; + } + + if (data.event === TASK_START) { + this.taskCount++; + } + + if (data.event === JOB_END) { + this.statsEvent = data; + } + }; + + this.updateHostCounts = () => { + const countedHostNames = []; + + const counts = Object.assign(...HOST_STATUS_KEYS.map(key => ({ [key]: 0 }))); + + HOST_STATUS_KEYS.forEach(key => { + const hostData = _.get(this.statsEvent, ['event_data', key], {}); + + Object.keys(hostData).forEach(hostName => { + const isAlreadyCounted = (countedHostNames.indexOf(hostName) > -1); + const shouldBeCounted = ((!isAlreadyCounted) && hostData[hostName] > 0); + + if (shouldBeCounted) { + countedHostNames.push(hostName); + counts[key]++; + } + }); + }); + + this.hostCount = countedHostNames.length; + this.hostStatusCounts = counts; + }; + + this.updateStats = () => { + this.updateHostCounts(); + + if (this.statsEvent) { + this.setFinished(this.statsEvent.created); + this.setJobStatus(this.statsEvent.failed ? 'failed' : 'successful'); + } + }; + + this.isRunning = () => (Boolean(this.started) && !this.finished) || + (this.jobStatus === 'running') || + (this.jobStatus === 'pending') || + (this.jobStatus === 'waiting'); + + this.isExpectingStatsEvent = () => (this.jobType === 'job') || + (this.jobType === 'project_update'); + + this.getPlayCount = () => this.playCount; + this.getTaskCount = () => this.taskCount; + this.getHostCount = () => this.hostCount; + this.getHostStatusCounts = () => this.hostStatusCounts || {}; + this.getJobStatus = () => this.jobStatus; + this.getProjectStatus = () => this.projectStatus; + this.getProjectUpdateId = () => this.projectUpdateId; + this.getElapsed = () => this.elapsed; + this.getStatsEvent = () => this.statsEvent; + this.getStarted = () => this.started; + this.getFinished = () => this.finished; + + this.setJobStatus = status => { + this.jobStatus = status; + + if (!this.isExpectingStatsEvent() && _.includes(FINISHED, status)) { + if (this.latestTime) { + this.setFinished(this.latestTime); + + if (!this.started && this.elapsed) { + this.started = moment(this.latestTime).subtract(this.elapsed, 'seconds'); + } + } + } + }; + + this.setProjectStatus = status => { + this.projectStatus = status; + }; + + this.setProjectUpdateId = id => { + this.projectUpdateId = id; + }; + + this.setFinished = time => { + this.finished = time; + }; + + this.resetCounts = () => { + this.playCount = 0; + this.taskCount = 0; + this.hostCount = 0; + }; +} + +JobStatusService.$inject = [ + 'moment', +]; + +export default JobStatusService; diff --git a/awx/ui/client/features/templates/index.controller.js b/awx/ui/client/features/templates/index.controller.js new file mode 100644 index 0000000000..5f305345a9 --- /dev/null +++ b/awx/ui/client/features/templates/index.controller.js @@ -0,0 +1,19 @@ +function IndexTemplatesController ($scope, strings, dataset) { + let vm = this; + vm.strings = strings; + vm.count = dataset.data.count; + + $scope.$on('updateDataset', (e, { count }) => { + if (count) { + vm.count = count; + } + }); +} + +IndexTemplatesController.$inject = [ + '$scope', + 'TemplatesStrings', + 'Dataset', +]; + +export default IndexTemplatesController; diff --git a/awx/ui/client/features/templates/index.js b/awx/ui/client/features/templates/index.js index a2f6ab25a4..fd0a49b45a 100644 --- a/awx/ui/client/features/templates/index.js +++ b/awx/ui/client/features/templates/index.js @@ -1,11 +1,9 @@ import TemplatesStrings from './templates.strings'; -import ListController from './list-templates.controller'; const MODULE_NAME = 'at.features.templates'; angular .module(MODULE_NAME, []) - .controller('ListController', ListController) .service('TemplatesStrings', TemplatesStrings); export default MODULE_NAME; diff --git a/awx/ui/client/features/templates/index.view.html b/awx/ui/client/features/templates/index.view.html new file mode 100644 index 0000000000..6323fd8129 --- /dev/null +++ b/awx/ui/client/features/templates/index.view.html @@ -0,0 +1,10 @@ +
+ + + {{:: vm.strings.get('list.PANEL_TITLE') }} +
+ {{ vm.count }} +
+
+
+
diff --git a/awx/ui/client/features/templates/list.view.html b/awx/ui/client/features/templates/list.view.html deleted file mode 100644 index a6557423ce..0000000000 --- a/awx/ui/client/features/templates/list.view.html +++ /dev/null @@ -1,124 +0,0 @@ -
- - - {{:: vm.strings.get('list.PANEL_TITLE') }} -
- {{ template_dataset.count }} -
-
- - -
- - -
- - -
-
- - - -
- -
-
- - - - - - - - - - - - - - - - - - - -
-
- - - - - - - - -
-
-
- - -
-
diff --git a/awx/ui/client/features/templates/routes/organizationsTemplatesList.route.js b/awx/ui/client/features/templates/routes/organizationsTemplatesList.route.js new file mode 100644 index 0000000000..3bdd705f1e --- /dev/null +++ b/awx/ui/client/features/templates/routes/organizationsTemplatesList.route.js @@ -0,0 +1,65 @@ +import { N_ } from '../../../src/i18n'; +import templatesListController from '../templatesList.controller'; +import indexController from '../index.controller'; + +const indexTemplate = require('~features/templates/index.view.html'); +const templatesListTemplate = require('~features/templates/templatesList.view.html'); + +export default { + url: "/:organization_id/job_templates", + name: 'organizations.job_templates', + params: { + template_search: { + dynamic: true, + value: { + type: 'workflow_job_template,job_template', + }, + } + }, + ncyBreadcrumb: { + label: N_("JOB TEMPLATES") + }, + views: { + 'form': { + templateUrl: indexTemplate, + controller: indexController, + controllerAs: 'vm' + }, + 'templatesList@organizations.job_templates': { + controller: templatesListController, + templateUrl: templatesListTemplate, + controllerAs: 'vm', + } + }, + resolve: { + resolvedModels: [ + 'JobTemplateModel', + 'WorkflowJobTemplateModel', + (JobTemplate, WorkflowJobTemplate) => { + const models = [ + new JobTemplate(['options']), + new WorkflowJobTemplate(['options']), + ]; + return Promise.all(models); + }, + ], + Dataset: [ + '$stateParams', + 'Wait', + 'GetBasePath', + 'QuerySet', + ($stateParams, Wait, GetBasePath, qs) => { + const searchPath = GetBasePath('unified_job_templates'); + + const searchParam = _.assign($stateParams.template_search, { + or__project__organization: $stateParams.organization_id, + or__jobtemplate__inventory__organization: $stateParams.organization_id, + }); + + Wait('start'); + return qs.search(searchPath, searchParam) + .finally(() => Wait('stop')); + } + ], + } +}; diff --git a/awx/ui/client/features/templates/routes/projectsTemplatesList.route.js b/awx/ui/client/features/templates/routes/projectsTemplatesList.route.js new file mode 100644 index 0000000000..8f6d916dbc --- /dev/null +++ b/awx/ui/client/features/templates/routes/projectsTemplatesList.route.js @@ -0,0 +1,56 @@ +import { N_ } from '../../../src/i18n'; +import templatesListController from '../templatesList.controller'; + +const templatesListTemplate = require('~features/templates/templatesList.view.html'); + +export default { + url: "/templates", + name: 'projects.edit.templates', + params: { + template_search: { + dynamic: true, + value: { + type: 'workflow_job_template,job_template', + }, + } + }, + ncyBreadcrumb: { + label: N_("JOB TEMPLATES") + }, + views: { + 'related': { + controller: templatesListController, + templateUrl: templatesListTemplate, + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: [ + 'JobTemplateModel', + 'WorkflowJobTemplateModel', + (JobTemplate, WorkflowJobTemplate) => { + const models = [ + new JobTemplate(['options']), + new WorkflowJobTemplate(['options']), + ]; + return Promise.all(models); + }, + ], + Dataset: [ + '$stateParams', + 'Wait', + 'GetBasePath', + 'QuerySet', + ($stateParams, Wait, GetBasePath, qs) => { + const searchPath = GetBasePath('unified_job_templates'); + + const searchParam = _.assign($stateParams.template_search, { + jobtemplate__project: $stateParams.project_id }); + + Wait('start'); + return qs.search(searchPath, searchParam) + .finally(() => Wait('stop')); + } + ], + } +}; diff --git a/awx/ui/client/features/templates/list.route.js b/awx/ui/client/features/templates/routes/templatesList.route.js similarity index 72% rename from awx/ui/client/features/templates/list.route.js rename to awx/ui/client/features/templates/routes/templatesList.route.js index e08b2fc863..8a912776cc 100644 --- a/awx/ui/client/features/templates/list.route.js +++ b/awx/ui/client/features/templates/routes/templatesList.route.js @@ -1,16 +1,14 @@ -import ListController from './list-templates.controller'; -const listTemplate = require('~features/templates/list.view.html'); -import { N_ } from '../../src/i18n'; +import { N_ } from '../../../src/i18n'; +import templatesListController from '../templatesList.controller'; +import indexController from '../index.controller'; + +const indexTemplate = require('~features/templates/index.view.html'); +const templatesListTemplate = require('~features/templates/templatesList.view.html'); export default { name: 'templates', route: '/templates', ncyBreadcrumb: { - // TODO: this would be best done with our - // strings file pattern, but it's not possible to - // get a handle on this route within a DI based - // on the state tree generation as present in - // src/templates currently label: N_("TEMPLATES") }, data: { @@ -33,8 +31,13 @@ export default { searchPrefix: 'template', views: { '@': { - controller: ListController, - templateUrl: listTemplate, + templateUrl: indexTemplate, + controller: indexController, + controllerAs: 'vm' + }, + 'templatesList@templates': { + controller: templatesListController, + templateUrl: templatesListTemplate, controllerAs: 'vm', } }, diff --git a/awx/ui/client/features/templates/templates.strings.js b/awx/ui/client/features/templates/templates.strings.js index 6f338c873f..896cfd87bd 100644 --- a/awx/ui/client/features/templates/templates.strings.js +++ b/awx/ui/client/features/templates/templates.strings.js @@ -28,11 +28,13 @@ function TemplatesStrings (BaseString) { SURVEY: t.s('Survey'), PREVIEW: t.s('Preview'), LAUNCH: t.s('LAUNCH'), + CONFIRM: t.s('CONFIRM'), SELECTED: t.s('SELECTED'), NO_CREDENTIALS_SELECTED: t.s('No credentials selected'), NO_INVENTORY_SELECTED: t.s('No inventory selected'), REVERT: t.s('REVERT'), CREDENTIAL_TYPE: t.s('Credential Type'), + CREDENTIAL_PASSWORD_WARNING: t.s('Credentials that require passwords on launch are not permitted for template schedules and workflow nodes. The following credentials must be removed or replaced to proceed:'), PASSWORDS_REQUIRED_HELP: t.s('Launching this job requires the passwords listed below. Enter and confirm each password before continuing.'), PLEASE_ENTER_PASSWORD: t.s('Please enter a password.'), credential_passwords: { @@ -50,6 +52,7 @@ function TemplatesStrings (BaseString) { CHOOSE_JOB_TYPE: t.s('Choose a job type'), CHOOSE_VERBOSITY: t.s('Choose a verbosity'), EXTRA_VARIABLES: t.s('Extra Variables'), + EXTRA_VARIABLES_HELP: t.s('

Pass extra command line variables to the playbook. This is the -e or --extra-vars command line parameter for ansible-playbook. Provide key/value pairs using either YAML or JSON.

JSON:
{
"somevar": "somevalue",
"password": "magic"
}
YAML:
---
somevar: somevalue
password: magic
'), PLEASE_ENTER_ANSWER: t.s('Please enter an answer.'), PLEASE_SELECT_VALUE: t.s('Please select a value'), VALID_INTEGER: t.s('Please enter an answer that is a valid integer.'), @@ -89,6 +92,10 @@ function TemplatesStrings (BaseString) { ns.warnings = { WORKFLOW_RESTRICTED_COPY: t.s('You do not have access to all resources used by this workflow. Resources that you don\'t have access to will not be copied and will result in an incomplete workflow.') }; + + ns.workflows = { + INVALID_JOB_TEMPLATE: t.s('This Job Template is missing a default inventory or project. This must be addressed in the Job Template form before this node can be saved.') + }; } TemplatesStrings.$inject = ['BaseStringService']; diff --git a/awx/ui/client/features/templates/list-templates.controller.js b/awx/ui/client/features/templates/templatesList.controller.js similarity index 89% rename from awx/ui/client/features/templates/list-templates.controller.js rename to awx/ui/client/features/templates/templatesList.controller.js index 321c991011..6ab8712e40 100644 --- a/awx/ui/client/features/templates/list-templates.controller.js +++ b/awx/ui/client/features/templates/templatesList.controller.js @@ -20,7 +20,9 @@ function ListTemplatesController( Prompt, resolvedModels, strings, - Wait + Wait, + qs, + GetBasePath ) { const vm = this || {}; const [jobTemplate, workflowTemplate] = resolvedModels; @@ -28,6 +30,9 @@ function ListTemplatesController( const choices = workflowTemplate.options('actions.GET.type.choices') .concat(jobTemplate.options('actions.GET.type.choices')); + let launchModalOpen = false; + let refreshAfterLaunchClose = false; + vm.strings = strings; vm.templateTypes = mapChoices(choices); vm.activeId = parseInt($state.params.job_template_id || $state.params.workflow_template_id); @@ -46,17 +51,36 @@ function ListTemplatesController( $scope.canAdd = ($scope.canAddJobTemplate || $scope.canAddWorkflowJobTemplate); // smart-search - const name = 'templates'; - const iterator = 'template'; - const key = 'template_dataset'; - - $scope.list = { iterator, name }; - $scope.collection = { iterator, basePath: 'unified_job_templates' }; - $scope[key] = Dataset.data; - $scope[name] = Dataset.data.results; + $scope.list = { + iterator: 'template', + name: 'templates' + }; + $scope.collection = { + iterator: 'template', + basePath: 'unified_job_templates' + }; + $scope.template_dataset = Dataset.data; + $scope.templates = Dataset.data.results; $scope.$on('updateDataset', (e, dataset) => { - $scope[key] = dataset; - $scope[name] = dataset.results; + $scope.template_dataset = dataset; + $scope.templates = dataset.results; + }); + + $scope.$on(`ws-jobs`, () => { + if (!launchModalOpen) { + refreshTemplates(); + } else { + refreshAfterLaunchClose = true; + } + }); + + $scope.$on('launchModalOpen', (evt, isOpen) => { + evt.stopPropagation(); + if (!isOpen && refreshAfterLaunchClose) { + refreshAfterLaunchClose = false; + refreshTemplates(); + } + launchModalOpen = isOpen; }); vm.isInvalid = (template) => { @@ -150,6 +174,15 @@ function ListTemplatesController( return html; }; + function refreshTemplates() { + let path = GetBasePath('unified_job_templates'); + qs.search(path, $state.params.template_search) + .then(function(searchResponse) { + $scope.template_dataset = searchResponse.data; + $scope.templates = $scope.template_dataset.results; + }); + } + function createErrorHandler(path, action) { return ({ data, status }) => { const hdr = strings.get('error.HEADER'); @@ -319,7 +352,9 @@ ListTemplatesController.$inject = [ 'Prompt', 'resolvedModels', 'TemplatesStrings', - 'Wait' + 'Wait', + 'QuerySet', + 'GetBasePath' ]; export default ListTemplatesController; diff --git a/awx/ui/client/features/templates/templatesList.view.html b/awx/ui/client/features/templates/templatesList.view.html new file mode 100644 index 0000000000..e68ddef8fb --- /dev/null +++ b/awx/ui/client/features/templates/templatesList.view.html @@ -0,0 +1,112 @@ + +
+ + +
+ + +
+
+ + +
+ + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + + +
+
+
+ + +
diff --git a/awx/ui/client/legacy/styles/ansible-ui.less b/awx/ui/client/legacy/styles/ansible-ui.less index 79e554c0f3..1963ac4361 100644 --- a/awx/ui/client/legacy/styles/ansible-ui.less +++ b/awx/ui/client/legacy/styles/ansible-ui.less @@ -879,7 +879,6 @@ input[type="checkbox"].checkbox-no-label { .checkbox-options { font-weight: normal; - padding-right: 20px; } /* Display list actions next to search widget */ @@ -2328,6 +2327,10 @@ body { margin-top: 20px; } +.Panel--noBottomPadding { + padding-bottom: 0px; +} + .Panel-hidden { display: none; } diff --git a/awx/ui/client/legacy/styles/forms.less b/awx/ui/client/legacy/styles/forms.less index 9cc5779d36..e18c92cfb9 100644 --- a/awx/ui/client/legacy/styles/forms.less +++ b/awx/ui/client/legacy/styles/forms.less @@ -102,6 +102,10 @@ flex-wrap:wrap; } +.Form-tabHolder--licenseSelected { + margin-bottom: -20px; +} + .Form-tabs { flex: 1 0 auto; display: flex; @@ -784,3 +788,8 @@ input[type='radio']:checked:before { border-color: @b7grey; background-color: @ebgrey; } + +.Form-checkboxRow { + display: flex; + clear: left; +} diff --git a/awx/ui/client/lib/components/_index.less b/awx/ui/client/lib/components/_index.less index ae992079c9..72c14fe4b5 100644 --- a/awx/ui/client/lib/components/_index.less +++ b/awx/ui/client/lib/components/_index.less @@ -10,3 +10,4 @@ @import 'tabs/_index'; @import 'truncate/_index'; @import 'utility/_index'; +@import 'code-mirror/_index'; diff --git a/awx/ui/client/lib/components/code-mirror/_index.less b/awx/ui/client/lib/components/code-mirror/_index.less new file mode 100644 index 0000000000..770546b151 --- /dev/null +++ b/awx/ui/client/lib/components/code-mirror/_index.less @@ -0,0 +1,76 @@ +.noselect { + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Chrome/Safari/Opera */ + -khtml-user-select: none; /* Konqueror */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + not supported by any browser */ +} + +.atCodeMirror-label{ + display: flex; + width: 100%; + margin-bottom: 5px; +} + +.atCodeMirror-labelLeftSide{ + flex: 1 0 auto; +} + +.atCodeMirror-labelText{ + text-transform: uppercase; + color: #707070; + font-weight: normal; + font-size: small; + padding-right: 5px; + width: 100%; +} + +.atCodeMirror-toggleContainer{ + margin: 0 0 0 10px; + display: initial; + padding-bottom: 5px; +} + +.atCodeMirror-expandTextContainer{ + flex: 1 0 auto; + text-align: right; + font-weight: normal; + color: @default-link; + cursor: pointer; + font-size: 12px; +} + +.CodeMirror-modal .modal-dialog{ + width: calc(~"100% - 200px"); + height: calc(~"100vh - 80px"); +} + + +@media screen and (min-width: 768px){ + .NetworkingExtraVars .modal-dialog{ + width: 700px; + } +} + +.CodeMirror-modal .modal-dialog{ + width: calc(~"100% - 200px"); + height: calc(~"100vh - 80px"); +} + +.CodeMirror-modal .modal-content{ + height: 100%; +} + +.NetworkingExtraVars .CodeMirror{ + overflow-x: hidden; +} + +.CodeMirror-modalControls{ + float: right; + margin-top: 15px; + button { + margin-left: 10px; + } +} diff --git a/awx/ui/client/lib/components/code-mirror/code-mirror.directive.js b/awx/ui/client/lib/components/code-mirror/code-mirror.directive.js new file mode 100644 index 0000000000..6d74f2a6aa --- /dev/null +++ b/awx/ui/client/lib/components/code-mirror/code-mirror.directive.js @@ -0,0 +1,78 @@ +const templateUrl = require('~components/code-mirror/code-mirror.partial.html'); + +const CodeMirrorID = 'codemirror-extra-vars'; +const CodeMirrorModalID = '#CodeMirror-modal'; +const ParseVariable = 'parseType'; +const CodeMirrorVar = 'variables'; +const ParseType = 'yaml'; + +function atCodeMirrorController ( + $scope, + strings, + ParseTypeChange, + ParseVariableString +) { + const vm = this; + + function init (vars) { + $scope.variables = ParseVariableString(_.cloneDeep(vars)); + $scope.parseType = ParseType; + const options = { + scope: $scope, + variable: CodeMirrorVar, + parse_variable: ParseVariable, + field_id: CodeMirrorID, + readOnly: $scope.disabled + }; + ParseTypeChange(options); + } + + function expand () { + vm.expanded = true; + } + + function close () { + $(CodeMirrorModalID).off('hidden.bs.modal'); + $(CodeMirrorModalID).modal('hide'); + $('.popover').popover('hide'); + vm.expanded = false; + } + + vm.strings = strings; + vm.expanded = false; + vm.close = close; + vm.expand = expand; + if ($scope.init) { + $scope.init = init; + } + init($scope.variables); +} + +atCodeMirrorController.$inject = [ + '$scope', + 'CodeMirrorStrings', + 'ParseTypeChange', + 'ParseVariableString' +]; + +function atCodeMirrorTextarea () { + return { + restrict: 'E', + replace: true, + transclude: true, + templateUrl, + controller: atCodeMirrorController, + controllerAs: 'vm', + scope: { + disabled: '@', + label: '@', + labelClass: '@', + tooltip: '@', + tooltipPlacement: '@', + variables: '@', + init: '=' + } + }; +} + +export default atCodeMirrorTextarea; diff --git a/awx/ui/client/lib/components/code-mirror/code-mirror.partial.html b/awx/ui/client/lib/components/code-mirror/code-mirror.partial.html new file mode 100644 index 0000000000..98349fd3a3 --- /dev/null +++ b/awx/ui/client/lib/components/code-mirror/code-mirror.partial.html @@ -0,0 +1,60 @@ +
+
+
+ + {{ label || vm.strings.get('code_mirror.label.VARIABLES') }} + + + + +
+
+ + +
+
+
+
{{ vm.strings.get('label.EXPAND') }}
+
+ + + +
diff --git a/awx/ui/client/lib/components/code-mirror/code-mirror.strings.js b/awx/ui/client/lib/components/code-mirror/code-mirror.strings.js new file mode 100644 index 0000000000..0dae4bc6a5 --- /dev/null +++ b/awx/ui/client/lib/components/code-mirror/code-mirror.strings.js @@ -0,0 +1,55 @@ +function CodeMirrorStrings (BaseString) { + BaseString.call(this, 'code_mirror'); + + const { t } = this; + const ns = this.code_mirror; + + ns.label = { + EXTRA_VARIABLES: t.s('EXTRA VARIABLES'), + VARIABLES: t.s('VARIABLES'), + EXPAND: t.s('EXPAND'), + YAML: t.s('YAML'), + JSON: t.s('JSON') + + }; + + ns.tooltip = { + TOOLTIP: t.s(` +

+ Enter inventory variables using either JSON or YAML + syntax. Use the radio button to toggle between the two. +

+ JSON: +
+
+ { +
"somevar": "somevalue", +
"password": "magic" +
+ } +
+ YAML: +
+
+ --- +
somevar: somevalue +
password: magic +
+
+

+ View JSON examples at + www.json.org +

+

+ View YAML examples at + + docs.ansible.com +

`), + TOOLTIP_TITLE: t.s('EXTRA VARIABLES'), + JOB_RESULTS: t.s('Read-only view of extra variables added to the job template.') + }; +} + +CodeMirrorStrings.$inject = ['BaseStringService']; + +export default CodeMirrorStrings; diff --git a/awx/ui/client/lib/components/code-mirror/index.js b/awx/ui/client/lib/components/code-mirror/index.js new file mode 100644 index 0000000000..21d4aedc09 --- /dev/null +++ b/awx/ui/client/lib/components/code-mirror/index.js @@ -0,0 +1,12 @@ +import codemirror from './code-mirror.directive'; +import modal from './modal/code-mirror-modal.directive'; +import strings from './code-mirror.strings'; + +const MODULE_NAME = 'at.code.mirror'; + +angular.module(MODULE_NAME, []) + .directive('atCodeMirror', codemirror) + .directive('atCodeMirrorModal', modal) + .service('CodeMirrorStrings', strings); + +export default MODULE_NAME; diff --git a/awx/ui/client/lib/components/code-mirror/modal/code-mirror-modal.directive.js b/awx/ui/client/lib/components/code-mirror/modal/code-mirror-modal.directive.js new file mode 100644 index 0000000000..6a1837272c --- /dev/null +++ b/awx/ui/client/lib/components/code-mirror/modal/code-mirror-modal.directive.js @@ -0,0 +1,84 @@ +const templateUrl = require('~components/code-mirror/modal/code-mirror-modal.partial.html'); + +const CodeMirrorModalID = '#CodeMirror-modal'; +const CodeMirrorID = 'codemirror-extra-vars-modal'; +const ParseVariable = 'parseType'; +const CodeMirrorVar = 'extra_variables'; +const ParseType = 'yaml'; +const ModalHeight = '#CodeMirror-modal .modal-dialog'; +const ModalHeader = '.atCodeMirror-label'; +const ModalFooter = '.CodeMirror-modalControls'; + +function atCodeMirrorModalController ( + $scope, + strings, + ParseTypeChange, + ParseVariableString +) { + const vm = this; + + function resize () { + const editor = $(`${CodeMirrorModalID} .CodeMirror`)[0].CodeMirror; + const height = $(ModalHeight).height() - $(ModalHeader).height() - + $(ModalFooter).height() - 100; + editor.setSize('100%', height); + } + + function toggle () { + $scope.parseTypeChange('parseType', 'extra_variables'); + setTimeout(resize, 0); + } + + function init () { + $(CodeMirrorModalID).modal('show'); + $scope.extra_variables = ParseVariableString(_.cloneDeep($scope.variables)); + $scope.parseType = ParseType; + const options = { + scope: $scope, + variable: CodeMirrorVar, + parse_variable: ParseVariable, + field_id: CodeMirrorID, + readOnly: $scope.disabled + }; + ParseTypeChange(options); + resize(); + $(CodeMirrorModalID).on('hidden.bs.modal', $scope.closeFn); + $(`${CodeMirrorModalID} .modal-dialog`).resizable({ + minHeight: 523, + minWidth: 600 + }); + $(`${CodeMirrorModalID} .modal-dialog`).on('resize', resize); + } + + vm.strings = strings; + vm.toggle = toggle; + init(); +} + +atCodeMirrorModalController.$inject = [ + '$scope', + 'CodeMirrorStrings', + 'ParseTypeChange', + 'ParseVariableString', +]; + +function atCodeMirrorModal () { + return { + restrict: 'E', + replace: true, + transclude: true, + templateUrl, + controller: atCodeMirrorModalController, + controllerAs: 'vm', + scope: { + disabled: '@', + label: '@', + labelClass: '@', + tooltip: '@', + variables: '@', + closeFn: '&' + } + }; +} + +export default atCodeMirrorModal; diff --git a/awx/ui/client/lib/components/code-mirror/modal/code-mirror-modal.partial.html b/awx/ui/client/lib/components/code-mirror/modal/code-mirror-modal.partial.html new file mode 100644 index 0000000000..056672a2b5 --- /dev/null +++ b/awx/ui/client/lib/components/code-mirror/modal/code-mirror-modal.partial.html @@ -0,0 +1,68 @@ + diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 513098dc76..a53da0676f 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -76,7 +76,11 @@ function ComponentsStrings (BaseString) { APPLICATIONS: t.s('Applications'), SETTINGS: t.s('Settings'), FOOTER_ABOUT: t.s('About'), - FOOTER_COPYRIGHT: t.s('Copyright © 2018 Red Hat, Inc.') + FOOTER_COPYRIGHT: t.s('Copyright © 2018 Red Hat, Inc.'), + VIEWS_HEADER: t.s('Views'), + RESOURCES_HEADER: t.s('Resources'), + ACCESS_HEADER: t.s('Access'), + ADMINISTRATION_HEADER: t.s('Administration') }; ns.relaunch = { @@ -87,6 +91,10 @@ function ComponentsStrings (BaseString) { FAILED: t.s('Failed') }; + ns.launchTemplate = { + DEFAULT: t.s('Start a job using this template') + }; + ns.list = { DEFAULT_EMPTY_LIST: t.s('Please add items to this list.') }; diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 9ac933628c..35b0e0193d 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -34,6 +34,7 @@ import tab from '~components/tabs/tab.directive'; import tabGroup from '~components/tabs/group.directive'; import topNavItem from '~components/layout/top-nav-item.directive'; import truncate from '~components/truncate/truncate.directive'; +import atCodeMirror from '~components/code-mirror'; import BaseInputController from '~components/input/base.controller'; import ComponentsStrings from '~components/components.strings'; @@ -42,7 +43,8 @@ const MODULE_NAME = 'at.lib.components'; angular .module(MODULE_NAME, [ - atLibServices + atLibServices, + atCodeMirror ]) .directive('atActionGroup', actionGroup) .directive('atDivider', divider) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index dc08df77db..adcc2f2505 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -70,6 +70,10 @@ height: @at-height-textarea; } +.at-Input-button--active { + .at-mixin-ButtonColor(at-color-info, at-color-default); +} + .at-Input--focus { border-color: @at-color-input-focus; } @@ -261,4 +265,12 @@ width: 16px; } } -} \ No newline at end of file + + input[type=range][disabled] { + &::-webkit-slider-thumb { + background: @at-color-disabled; + border: solid 1px @at-color-disabled; + cursor: not-allowed; + } + } +} diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html index e74c516ab6..88cd218e9c 100644 --- a/awx/ui/client/lib/components/input/lookup.partial.html +++ b/awx/ui/client/lib/components/input/lookup.partial.html @@ -21,7 +21,8 @@ ng-disabled="state._disabled || form.disabled"> + ng-if="state._lookupTags" + ng-disabled="state._disabled || form.disabled">
{{ tag.hostname }} diff --git a/awx/ui/client/lib/components/input/slider.partial.html b/awx/ui/client/lib/components/input/slider.partial.html index e4649149f8..e07abdadfa 100644 --- a/awx/ui/client/lib/components/input/slider.partial.html +++ b/awx/ui/client/lib/components/input/slider.partial.html @@ -6,7 +6,8 @@ ng-model="state._value" min="0" max="100" - ng-change="vm.slide(state._value)"/> + ng-change="vm.slide(state._value)" + ng-disabled="state._disabled || form.disabled"/>

{{ state._value }}%

diff --git a/awx/ui/client/lib/components/launchTemplateButton/_index.less b/awx/ui/client/lib/components/launchTemplateButton/_index.less index d5c39547ff..f0d68bd3d8 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/_index.less +++ b/awx/ui/client/lib/components/launchTemplateButton/_index.less @@ -1,6 +1,4 @@ .at-LaunchTemplate { - margin-left: 15px; - &--button { font-size: 16px; height: 30px; @@ -8,8 +6,9 @@ color: #848992; background-color: inherit; border: none; - border-radius: 4px; + border-radius: 5px; } + &--button:hover { background-color: @at-blue; color: white; diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js index 4081f29bff..bd82a2fa10 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.component.js @@ -6,22 +6,24 @@ const atLaunchTemplate = { template: '<' }, controller: ['JobTemplateModel', 'WorkflowJobTemplateModel', 'PromptService', '$state', - 'ProcessErrors', '$scope', 'TemplatesStrings', 'Alert', atLaunchTemplateCtrl], + 'ComponentsStrings', 'ProcessErrors', '$scope', 'TemplatesStrings', 'Alert', + atLaunchTemplateCtrl], controllerAs: 'vm' }; function atLaunchTemplateCtrl ( JobTemplate, WorkflowTemplate, PromptService, $state, - ProcessErrors, $scope, strings, Alert + componentsStrings, ProcessErrors, $scope, templatesStrings, Alert ) { const vm = this; const jobTemplate = new JobTemplate(); const workflowTemplate = new WorkflowTemplate(); + vm.strings = componentsStrings; const createErrorHandler = (path, action) => ({ data, status }) => { - const hdr = strings.get('error.HEADER'); - const msg = strings.get('error.CALL', { path, action, status }); + const hdr = templatesStrings.get('error.HEADER'); + const msg = templatesStrings.get('error.CALL', { path, action, status }); ProcessErrors($scope, data, status, null, { hdr, msg }); }; @@ -39,7 +41,7 @@ function atLaunchTemplateCtrl ( selectedJobTemplate .postLaunch({ id: vm.template.id }) .then(({ data }) => { - $state.go('jobResult', { id: data.job }, { reload: true }); + $state.go('jobz', { id: data.job, type: 'playbook' }, { reload: true }); }); } else { const promptData = { @@ -51,7 +53,7 @@ function atLaunchTemplateCtrl ( launchConf: launchData.data, launchOptions: launchOptions.data }), - triggerModalOpen: true, + triggerModalOpen: true }; if (launchData.data.survey_enabled) { @@ -111,7 +113,7 @@ function atLaunchTemplateCtrl ( } }); } else { - Alert(strings.get('error.UNKNOWN'), strings.get('alert.UNKNOWN_LAUNCH')); + Alert(templatesStrings.get('error.UNKNOWN'), templatesStrings.get('alert.UNKNOWN_LAUNCH')); } }; @@ -136,7 +138,7 @@ function atLaunchTemplateCtrl ( id: vm.promptData.template, launchData: jobLaunchData }).then((launchRes) => { - $state.go('jobResult', { id: launchRes.data.job }, { reload: true }); + $state.go('jobz', { id: launchRes.data.job, type: 'playbook' }, { reload: true }); }).catch(createErrorHandler('launch job template', 'POST')); } else if (vm.promptData.templateType === 'workflow_job_template') { workflowTemplate.create().postLaunch({ diff --git a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.partial.html b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.partial.html index 5be2373264..fc5c90de57 100644 --- a/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.partial.html +++ b/awx/ui/client/lib/components/launchTemplateButton/launchTemplateButton.partial.html @@ -1,7 +1,9 @@
- +
diff --git a/awx/ui/client/lib/components/layout/_index.less b/awx/ui/client/lib/components/layout/_index.less index fd4e29c144..ad2dc00958 100644 --- a/awx/ui/client/lib/components/layout/_index.less +++ b/awx/ui/client/lib/components/layout/_index.less @@ -90,30 +90,47 @@ overflow-y: auto; max-height: 100vh; min-width: @at-width-collapsed-side-nav; + width: @at-width-collapsed-side-nav; + overflow-x: hidden; z-index: @at-z-index-side-nav; + .at-Popover-container { + margin-top: 2px; + margin-left: -11px; + } + + .at-Popover-arrow { + padding-top: 7px; + margin-left: -10px; + } + .at-Layout-sideNavItem { background: inherit; color: @at-color-side-nav-content; display: flex; cursor: pointer; text-transform: uppercase; - - > i.fa { - padding-left: 20px; - } - + font-size: 12px; i { cursor: pointer; - color: @at-color-side-nav-content; - font-size: @at-height-side-nav-item-icon; + color: @at-color-side-nav-item-icon; + font-size: @at-font-size-side-nav-icon; padding: @at-padding-side-nav-item-icon; + padding-left: @at-padding-left-side-nav-item-icon; } i.fa-cubes { margin-left: -4px; } + i.fa-user { + margin-left: 1px; + } + + i.fa-users { + margin-left: -1px; + } + &:hover, &.is-active { background: @at-color-side-nav-item-background-hover; @@ -127,12 +144,37 @@ i.fa-cubes { margin-left: -9px; } + + i.fa-user { + margin-left: -4px; + } + + i.fa-users { + margin-left: -6px; + } } } + .at-Layout-sideNavToggle { + padding-top: @at-padding-top-side-nav-toggle; + + i { + padding-left: @at-padding-left-side-nav-toggle-icon; + } + } + .at-Layout-sideNavSpacer { - background: inherit; - height: 5px; + border-bottom: 1px solid @at-color-side-nav-space-collapsed-border; + padding: 0; + margin: @at-margin-side-nav-space-collapsed; + } + + .at-Layout-sideNavSpacer--first { + display: none; + } + + .at-Layout-sideNavHeader { + display: none; } &--expanded { @@ -143,11 +185,33 @@ justify-content: flex-start; align-items: center; padding-right: @at-padding-between-side-nav-icon-text; + + i { + padding-left: @at-padding-left-side-nav-item-icon-expanded; + } } + .at-Layout-main { padding-left: @at-width-expanded-side-nav; } + + .at-Layout-sideNavSpacer--first { + display: inherit; + } + + .at-Layout-sideNavSpacer { + height: @at-height-side-nav-spacer; + font-size: @at-font-size-side-nav-space; + color: @at-color-side-nav-item-spacer; + padding: @at-padding-side-nav-item-spacer; + text-transform: uppercase; + border-bottom: 0; + margin: 0; + } + + .at-Layout-sideNavHeader { + display: inherit; + } } } @@ -191,9 +255,13 @@ .at-Layout-sideNavItem.at-Layout-sideNavToggle { display: flex; - height: 40px; + height: @at-height-side-nav-toggle-mobile; align-items: center; - width: 55px; + width: @at-width-side-nav-toggle-mobile; + + i { + padding-bottom: @at-padding-bottom-side-nav-toggle-mobile; + } } .at-Layout-sideNavItem, diff --git a/awx/ui/client/lib/components/layout/layout.directive.js b/awx/ui/client/lib/components/layout/layout.directive.js index f853c5bb19..c6fddc51e4 100644 --- a/awx/ui/client/lib/components/layout/layout.directive.js +++ b/awx/ui/client/lib/components/layout/layout.directive.js @@ -1,6 +1,6 @@ const templateUrl = require('~components/layout/layout.partial.html'); -function AtLayoutController ($scope, strings, $transitions) { +function AtLayoutController ($scope, $http, strings, ProcessErrors, $transitions) { const vm = this || {}; $transitions.onSuccess({}, (transition) => { @@ -9,10 +9,14 @@ function AtLayoutController ($scope, strings, $transitions) { $scope.$watch('$root.current_user', (val) => { vm.isLoggedIn = val && val.username; - if (val) { + if (!_.isEmpty(val)) { vm.isSuperUser = $scope.$root.user_is_superuser || $scope.$root.user_is_system_auditor; vm.currentUsername = val.username; vm.currentUserId = val.id; + + if (!vm.isSuperUser) { + checkOrgAdmin(); + } } }); @@ -32,9 +36,27 @@ function AtLayoutController ($scope, strings, $transitions) { return strings.get(string); } }; + + function checkOrgAdmin () { + const usersPath = `/api/v2/users/${vm.currentUserId}/admin_of_organizations/`; + $http.get(usersPath) + .then(({ data }) => { + if (data.count > 0) { + vm.isOrgAdmin = true; + } else { + vm.isOrgAdmin = false; + } + }) + .catch(({ data, status }) => { + ProcessErrors(null, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { path: usersPath, action: 'GET', status }) + }); + }); + } } -AtLayoutController.$inject = ['$scope', 'ComponentsStrings', '$transitions']; +AtLayoutController.$inject = ['$scope', '$http', 'ComponentsStrings', 'ProcessErrors', '$transitions']; function atLayout () { return { diff --git a/awx/ui/client/lib/components/layout/layout.partial.html b/awx/ui/client/lib/components/layout/layout.partial.html index b1ada5e000..4714a23172 100644 --- a/awx/ui/client/lib/components/layout/layout.partial.html +++ b/awx/ui/client/lib/components/layout/layout.partial.html @@ -31,6 +31,11 @@
+
+ + {{:: $parent.layoutVm.getString('VIEWS_HEADER') }} + +
@@ -39,38 +44,49 @@ -
- +
+ + {{:: $parent.layoutVm.getString('RESOURCES_HEADER') }} + +
+ - + - + -
+
+ + {{:: $parent.layoutVm.getString('ACCESS_HEADER') }} + +
-
- +
+ + {{:: $parent.layoutVm.getString('ADMINISTRATION_HEADER') }} + +
+ - + ng-show="$parent.layoutVm.isSuperUser || $parent.layoutVm.isOrgAdmin"> -
diff --git a/awx/ui/client/lib/components/layout/side-nav-item.directive.js b/awx/ui/client/lib/components/layout/side-nav-item.directive.js index ab52d74964..d4b11bf716 100644 --- a/awx/ui/client/lib/components/layout/side-nav-item.directive.js +++ b/awx/ui/client/lib/components/layout/side-nav-item.directive.js @@ -4,7 +4,7 @@ function atSideNavItemLink (scope, element, attrs, ctrl) { [scope.navVm, scope.layoutVm] = ctrl; } -function AtSideNavItemController ($state, $scope, strings) { +function AtSideNavItemController ($scope, strings) { const vm = this || {}; $scope.$watch('layoutVm.currentState', current => { @@ -21,10 +21,6 @@ function AtSideNavItemController ($state, $scope, strings) { } }); - vm.go = () => { - $state.go($scope.route, {}, { reload: true }); - }; - vm.tooltip = { popover: { text: strings.get(`layout.${$scope.name}`), @@ -36,7 +32,7 @@ function AtSideNavItemController ($state, $scope, strings) { }; } -AtSideNavItemController.$inject = ['$state', '$scope', 'ComponentsStrings']; +AtSideNavItemController.$inject = ['$scope', 'ComponentsStrings']; function atSideNavItem () { return { diff --git a/awx/ui/client/lib/components/layout/side-nav-item.partial.html b/awx/ui/client/lib/components/layout/side-nav-item.partial.html index cea8489b5c..ca8ff13812 100644 --- a/awx/ui/client/lib/components/layout/side-nav-item.partial.html +++ b/awx/ui/client/lib/components/layout/side-nav-item.partial.html @@ -1,4 +1,4 @@ -
@@ -7,4 +7,4 @@ {{ layoutVm.getString(name) }} -
+ diff --git a/awx/ui/client/lib/components/layout/side-nav.directive.js b/awx/ui/client/lib/components/layout/side-nav.directive.js index f089d54eee..3fbf7cd86f 100644 --- a/awx/ui/client/lib/components/layout/side-nav.directive.js +++ b/awx/ui/client/lib/components/layout/side-nav.directive.js @@ -16,7 +16,7 @@ function AtSideNavController ($scope, $window) { const vm = this || {}; const breakpoint = 700; - vm.isExpanded = false; + vm.isExpanded = true; vm.toggleExpansion = () => { vm.isExpanded = !vm.isExpanded; diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index d053764fe0..dfcb74adad 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -40,6 +40,7 @@ } .at-List-toolbarActionButton { + border: none; border-radius: @at-border-radius; min-width: 80px; } @@ -67,12 +68,8 @@ } .at-Row { - display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; - padding: @at-padding-list-row; - position: relative; + display: grid; + grid-template-columns: 10px 1fr; } .at-Row--active { @@ -85,15 +82,21 @@ border-left: @at-white solid 1px; } +.at-Row--active .at-Row-content { + margin-left: -5px; +} + +.at-Row ~ .at-Row { + border-top-left-radius: 0px; + border-top-right-radius: 0px; + border-top: @at-border-default-width solid @at-color-list-border; +} + .at-Row--invalid { align-items: center; background: @at-color-error; display: flex; - height: 100%; justify-content: center; - left: 0; - position: absolute; - width: @at-space-2x; .at-Popover { padding: 0; @@ -108,31 +111,15 @@ } } -.at-Row ~ .at-Row { - border-top-left-radius: 0px; - border-top-right-radius: 0px; - border-top: @at-border-default-width solid @at-color-list-border; -} - -.at-Row--rowLayout { +.at-Row-content { + align-items: center; display: flex; - flex-direction: row; - - .at-RowItem { - margin-right: @at-space-4x; - - &-label { - width: auto; - } - } + flex-wrap: wrap; + grid-column-start: 2; + padding: @at-padding-list-row; } -.at-RowStatus { - align-self: flex-start; - margin: 0 10px 0 0; -} - -.at-Row-firstColumn { +.at-Row-toggle { margin-right: @at-space-4x; } @@ -141,12 +128,12 @@ } .at-Row-items { - align-self: flex-start; flex: 1; } .at-RowItem { - display: flex; + display: grid; + grid-template-columns: 120px 1fr; align-items: center; line-height: @at-height-list-row-item; } @@ -156,6 +143,7 @@ } .at-RowItem--isHeader { + display: flex; color: @at-color-body-text; margin-bottom: @at-margin-bottom-list-header; line-height: @at-line-height-list-row-item-header; @@ -171,6 +159,12 @@ .at-RowItem--labels { line-height: @at-line-height-list-row-item-labels; + display: flex; + flex-wrap: wrap; + + * { + font-size: 10px; + } } .at-RowItem-header { @@ -178,6 +172,7 @@ } .at-RowItem-tagContainer { + display: flex; margin-left: @at-margin-left-list-row-item-tag-container; } @@ -210,8 +205,6 @@ .at-RowItem-label { text-transform: uppercase; - width: auto; - width: @at-width-list-row-item-label; color: @at-color-list-row-item-label; font-size: @at-font-size; } @@ -280,6 +273,7 @@ @media screen and (max-width: @at-breakpoint-compact-list) { .at-Row-actions { flex-direction: column; + align-items: center; } .at-RowAction { diff --git a/awx/ui/client/lib/components/list/list.directive.js b/awx/ui/client/lib/components/list/list.directive.js index 6ff88e506f..7c6a1c1adc 100644 --- a/awx/ui/client/lib/components/list/list.directive.js +++ b/awx/ui/client/lib/components/list/list.directive.js @@ -20,6 +20,7 @@ function atList () { templateUrl, scope: { results: '=', + emptyListReason: '@' }, link: atListLink, controller: AtListController, diff --git a/awx/ui/client/lib/components/list/row-item.directive.js b/awx/ui/client/lib/components/list/row-item.directive.js index 296aa28249..ea116cc5cc 100644 --- a/awx/ui/client/lib/components/list/row-item.directive.js +++ b/awx/ui/client/lib/components/list/row-item.directive.js @@ -19,6 +19,7 @@ function atRowItem () { labelState: '@', value: '@', valueLink: '@', + valueBindHtml: '@', smartStatus: '=?', tagValues: '=?', // TODO: add see more for tags if applicable diff --git a/awx/ui/client/lib/components/list/row-item.partial.html b/awx/ui/client/lib/components/list/row-item.partial.html index d504f0f928..4783993acc 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -1,5 +1,5 @@
+ ng-show="status || headerValue || value || valueBindHtml || (smartStatus && smartStatus.summary_fields.recent_jobs.length) || (tagValues && tagValues.length)"> +
+
diff --git a/awx/ui/client/lib/components/list/row.directive.js b/awx/ui/client/lib/components/list/row.directive.js index 50adb3d8a4..98805a1cf7 100644 --- a/awx/ui/client/lib/components/list/row.directive.js +++ b/awx/ui/client/lib/components/list/row.directive.js @@ -7,7 +7,9 @@ function atRow () { transclude: true, templateUrl, scope: { - templateId: '@' + templateId: '@', + invalid: '=', + invalidTooltip: '=' } }; } diff --git a/awx/ui/client/lib/components/list/row.partial.html b/awx/ui/client/lib/components/list/row.partial.html index 385a825bc6..0f1d88d22d 100644 --- a/awx/ui/client/lib/components/list/row.partial.html +++ b/awx/ui/client/lib/components/list/row.partial.html @@ -1,2 +1,6 @@ -
+
+
+ +
+
diff --git a/awx/ui/client/lib/components/modal/modal.directive.js b/awx/ui/client/lib/components/modal/modal.directive.js index 302ff92a03..3f77d374e7 100644 --- a/awx/ui/client/lib/components/modal/modal.directive.js +++ b/awx/ui/client/lib/components/modal/modal.directive.js @@ -12,7 +12,7 @@ function atModalLink (scope, el, attrs, controllers) { }); } -function AtModalController (eventService, strings) { +function AtModalController ($timeout, eventService, strings) { const vm = this; let overlay; @@ -26,6 +26,7 @@ function AtModalController (eventService, strings) { vm.modal = scope[scope.ns].modal; vm.modal.show = vm.show; vm.modal.hide = vm.hide; + vm.modal.onClose = scope.onClose; }; vm.show = (title, message) => { @@ -48,6 +49,10 @@ function AtModalController (eventService, strings) { setTimeout(() => { overlay.style.display = 'none'; }, DEFAULT_ANIMATION_DURATION); + + if (vm.modal.onClose) { + vm.modal.onClose(); + } }; vm.clickToHide = event => { @@ -58,6 +63,7 @@ function AtModalController (eventService, strings) { } AtModalController.$inject = [ + '$timeout', 'EventService', 'ComponentsStrings' ]; diff --git a/awx/ui/client/lib/components/panel/_index.less b/awx/ui/client/lib/components/panel/_index.less index 7e50d8593a..b89faeb405 100644 --- a/awx/ui/client/lib/components/panel/_index.less +++ b/awx/ui/client/lib/components/panel/_index.less @@ -44,3 +44,15 @@ text-align: center; margin-left: 5px; } + +.at-Panel-label { + text-transform: uppercase; + color: @default-interface-txt; + font-size: 12px; + font-weight: normal!important; + width: 30%; + + @media screen and (max-width: @breakpoint-md) { + flex: 2.5 0 auto; + } +} diff --git a/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js b/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js index 045afd3585..df97883dc4 100644 --- a/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js +++ b/awx/ui/client/lib/components/relaunchButton/relaunchButton.component.js @@ -101,20 +101,35 @@ function atRelaunchCtrl ( }; }); } else { - jobObj.postRelaunch({ - id: vm.job.id - }).then((launchRes) => { - if (!$state.includes('jobs')) { - $state.go('jobResult', { id: launchRes.data.id }, { reload: true }); - } - }); + const launchParams = { + id: vm.job.id, + }; + + if (_.has(option, 'name')) { + launchParams.relaunchData = { + hosts: (option.name).toLowerCase() + }; + } + + jobObj.postRelaunch(launchParams) + .then((launchRes) => { + if (!$state.includes('jobs')) { + const relaunchType = launchRes.data.type === 'job' ? 'playbook' : launchRes.data.type; + $state.go('jobz', { id: launchRes.data.id, type: relaunchType }, { reload: true }); + } + }).catch(({ data, status, config }) => { + ProcessErrors($scope, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { path: `${config.url}`, status }) + }); + }); } }); }; vm.$onInit = () => { vm.showRelaunch = vm.job.type !== 'system_job' && vm.job.summary_fields.user_capabilities.start; - vm.showDropdown = vm.job.type === 'job' && vm.job.failed === true; + vm.showDropdown = vm.job.type === 'job' && vm.job.status === 'failed'; vm.createDropdown(); vm.createTooltips(); @@ -153,8 +168,13 @@ function atRelaunchCtrl ( inventorySource.postUpdate(vm.job.inventory_source) .then((postUpdateRes) => { if (!$state.includes('jobs')) { - $state.go('inventorySyncStdout', { id: postUpdateRes.data.id }, { reload: true }); + $state.go('jobz', { id: postUpdateRes.data.id, type: 'inventory' }, { reload: true }); } + }).catch(({ data, status, config }) => { + ProcessErrors($scope, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { path: `${config.url}`, status }) + }); }); } else { Alert( @@ -172,8 +192,13 @@ function atRelaunchCtrl ( project.postUpdate(vm.job.project) .then((postUpdateRes) => { if (!$state.includes('jobs')) { - $state.go('scmUpdateStdout', { id: postUpdateRes.data.id }, { reload: true }); + $state.go('jobz', { id: postUpdateRes.data.id, type: 'project' }, { reload: true }); } + }).catch(({ data, status, config }) => { + ProcessErrors($scope, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { path: `${config.url}`, status }) + }); }); } else { Alert( @@ -191,6 +216,11 @@ function atRelaunchCtrl ( if (!$state.includes('jobs')) { $state.go('workflowResults', { id: launchRes.data.id }, { reload: true }); } + }).catch(({ data, status, config }) => { + ProcessErrors($scope, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { path: `${config.url}`, status }) + }); }); } else if (vm.job.type === 'ad_hoc_command') { const adHocCommand = new AdHocCommand(); @@ -208,8 +238,13 @@ function atRelaunchCtrl ( id: vm.job.id }).then((launchRes) => { if (!$state.includes('jobs')) { - $state.go('adHocJobStdout', { id: launchRes.data.id }, { reload: true }); + $state.go('jobz', { id: launchRes.data.id, type: 'command' }, { reload: true }); } + }).catch(({ data, status, config }) => { + ProcessErrors($scope, data, status, null, { + hdr: strings.get('error.HEADER'), + msg: strings.get('error.CALL', { path: `${config.url}`, status }) + }); }); } }); @@ -228,7 +263,7 @@ function atRelaunchCtrl ( relaunchData: PromptService.bundlePromptDataForRelaunch(vm.promptData) }).then((launchRes) => { if (!$state.includes('jobs')) { - $state.go('jobResult', { id: launchRes.data.job }, { reload: true }); + $state.go('jobz', { id: launchRes.data.job, type: 'playbook' }, { reload: true }); } }).catch(({ data, status }) => { ProcessErrors($scope, data, status, null, { diff --git a/awx/ui/client/lib/components/relaunchButton/relaunchButton.partial.html b/awx/ui/client/lib/components/relaunchButton/relaunchButton.partial.html index 69b3eea711..280380dcbc 100644 --- a/awx/ui/client/lib/components/relaunchButton/relaunchButton.partial.html +++ b/awx/ui/client/lib/components/relaunchButton/relaunchButton.partial.html @@ -30,5 +30,5 @@ ng-if="!vm.showDropdown"> - +
diff --git a/awx/ui/client/lib/models/AdHocCommand.js b/awx/ui/client/lib/models/AdHocCommand.js index c398219531..7bea2677ac 100644 --- a/awx/ui/client/lib/models/AdHocCommand.js +++ b/awx/ui/client/lib/models/AdHocCommand.js @@ -1,5 +1,5 @@ -let Base; let $http; +let BaseModel; function getRelaunch (params) { const req = { @@ -19,26 +19,31 @@ function postRelaunch (params) { return $http(req); } +function getStats () { + return Promise.resolve(null); +} + function AdHocCommandModel (method, resource, config) { - Base.call(this, 'ad_hoc_commands'); + BaseModel.call(this, 'ad_hoc_commands'); this.Constructor = AdHocCommandModel; this.postRelaunch = postRelaunch.bind(this); this.getRelaunch = getRelaunch.bind(this); + this.getStats = getStats.bind(this); return this.create(method, resource, config); } -function AdHocCommandModelLoader (BaseModel, _$http_) { - Base = BaseModel; +function AdHocCommandModelLoader (_$http_, _BaseModel_) { $http = _$http_; + BaseModel = _BaseModel_; return AdHocCommandModel; } AdHocCommandModelLoader.$inject = [ + '$http', 'BaseModel', - '$http' ]; export default AdHocCommandModelLoader; diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js index a2c202aa79..912d9a984c 100644 --- a/awx/ui/client/lib/models/Base.js +++ b/awx/ui/client/lib/models/Base.js @@ -104,6 +104,25 @@ function httpGet (config = {}) { if (config.params) { req.params = config.params; + + if (config.params.page_size) { + this.page.size = config.params.page_size; + this.page.current = 1; + + if (config.pageCache) { + this.page.cachedPages = this.page.cachedPages || {}; + this.page.cache = this.page.cache || {}; + this.page.limit = config.pageLimit || false; + + if (!_.has(this.page.cachedPages, 'root')) { + this.page.cachedPages.root = []; + } + + if (!_.has(this.page.cache, 'root')) { + this.page.cache.root = {}; + } + } + } } if (typeof config.resource === 'object') { @@ -118,6 +137,13 @@ function httpGet (config = {}) { .then(res => { this.model.GET = res.data; + if (config.pageCache) { + this.page.cache.root[this.page.current] = res.data.results; + this.page.cachedPages.root.push(this.page.current); + this.page.count = res.data.count; + this.page.last = Math.ceil(res.data.count / this.page.size); + } + return res; }); } @@ -328,24 +354,42 @@ function has (method, keys) { } function extend (method, related, config = {}) { - if (!related) { - related = method; - method = 'GET'; - } else { - method = method.toUpperCase(); + const req = this.parseRequestConfig(method.toUpperCase(), config); + + if (_.get(config, 'params.page_size')) { + this.page.size = config.params.page_size; + this.page.current = 1; + + if (config.pageCache) { + this.page.cachedPages = this.page.cachedPages || {}; + this.page.cache = this.page.cache || {}; + this.page.limit = config.pageLimit || false; + + if (!_.has(this.page.cachedPages, `related.${related}`)) { + _.set(this.page.cachedPages, `related.${related}`, []); + } + + if (!_.has(this.page.cache, `related.${related}`)) { + _.set(this.page.cache, `related.${related}`, []); + } + } } - if (this.has(method, `related.${related}`)) { - const req = { - method, - url: this.get(`related.${related}`) - }; + if (this.has(req.method, `related.${related}`)) { + req.url = this.get(`related.${related}`); Object.assign(req, config); return $http(req) .then(({ data }) => { - this.set(method, `related.${related}`, data); + this.set(req.method, `related.${related}`, data); + + if (config.pageCache) { + this.page.cache.related[related][this.page.current] = data.results; + this.page.cachedPages.related[related].push(this.page.current); + this.page.count = data.count; + this.page.last = Math.ceil(data.count / this.page.size); + } return this; }); @@ -354,6 +398,97 @@ function extend (method, related, config = {}) { return Promise.reject(new Error(`No related property, ${related}, exists`)); } +function goToPage (config) { + const params = config.params || {}; + const { page } = config; + + let url; + let key; + let pageNumber; + let pageCache; + let pagesInCache; + + if (config.related) { + url = `${this.endpoint}${config.related}/`; + key = `related.${config.related}`; + } else { + url = this.endpoint; + key = 'root'; + } + + params.page_size = this.page.size; + + if (page === 'next') { + pageNumber = this.page.current + 1; + } else if (page === 'previous') { + pageNumber = this.page.current - 1; + } else if (page === 'first') { + pageNumber = 1; + } else if (page === 'last') { + pageNumber = this.page.last; + } else { + pageNumber = page; + } + + if (pageNumber < 1 || pageNumber > this.page.last) { + return Promise.resolve(null); + } + + this.page.current = pageNumber; + + if (this.page.cache) { + pageCache = _.get(this.page.cache, key); + pagesInCache = _.get(this.page.cachedPages, key); + + if (_.has(pageCache, pageNumber)) { + return Promise.resolve({ + results: pageCache[pageNumber], + page: pageNumber + }); + } + } + + params.page_size = this.page.size; + params.page = pageNumber; + + const req = { + method: 'GET', + url, + params + }; + + return $http(req) + .then(({ data }) => { + if (pageCache) { + pageCache[pageNumber] = data.results; + pagesInCache.push(pageNumber); + + if (pagesInCache.length > this.page.limit) { + const pageToDelete = pagesInCache.shift(); + + delete pageCache[pageToDelete]; + } + } + + return { + results: data.results, + page: pageNumber + }; + }); +} + +function next (config = {}) { + config.page = 'next'; + + return this.goToPage(config); +} + +function prev (config = {}) { + config.page = 'previous'; + + return this.goToPage(config); +} + function normalizePath (resource) { const version = '/api/v2/'; @@ -463,6 +598,10 @@ function create (method, resource, config) { return this; } + if (req.resource) { + this.setEndpoint(req.resource); + } + this.promise = this.request(req); if (req.graft) { @@ -473,6 +612,14 @@ function create (method, resource, config) { .then(() => this); } +function setEndpoint (resource) { + if (Array.isArray(resource)) { + this.endpoint = `${this.path}${resource[0]}/`; + } else { + this.endpoint = `${this.path}${resource}/`; + } +} + function parseRequestConfig (method, resource, config) { if (!method) { return null; @@ -525,19 +672,23 @@ function BaseModel (resource, settings) { this.create = create; this.find = find; this.get = get; + this.goToPage = goToPage; this.graft = graft; this.has = has; this.isEditable = isEditable; this.isCacheable = isCacheable; this.isCreatable = isCreatable; this.match = match; + this.next = next; this.normalizePath = normalizePath; this.options = options; this.parseRequestConfig = parseRequestConfig; + this.prev = prev; this.request = request; this.requestWithCache = requestWithCache; this.search = search; this.set = set; + this.setEndpoint = setEndpoint; this.unset = unset; this.extend = extend; this.copy = copy; @@ -552,6 +703,7 @@ function BaseModel (resource, settings) { delete: httpDelete.bind(this) }; + this.page = {}; this.model = {}; this.path = this.normalizePath(resource); this.label = strings.get(`${resource}.LABEL`); diff --git a/awx/ui/client/lib/models/InventoryUpdate.js b/awx/ui/client/lib/models/InventoryUpdate.js new file mode 100644 index 0000000000..668a05459d --- /dev/null +++ b/awx/ui/client/lib/models/InventoryUpdate.js @@ -0,0 +1,27 @@ +let BaseModel; + +function getStats () { + return Promise.resolve(null); +} + +function InventoryUpdateModel (method, resource, config) { + BaseModel.call(this, 'inventory_updates'); + + this.getStats = getStats.bind(this); + + this.Constructor = InventoryUpdateModel; + + return this.create(method, resource, config); +} + +function InventoryUpdateModelLoader (_BaseModel_) { + BaseModel = _BaseModel_; + + return InventoryUpdateModel; +} + +InventoryUpdateModelLoader.$inject = [ + 'BaseModel' +]; + +export default InventoryUpdateModelLoader; diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js index 7d87f82330..7dd58482a5 100644 --- a/awx/ui/client/lib/models/Job.js +++ b/awx/ui/client/lib/models/Job.js @@ -1,5 +1,5 @@ -let Base; let $http; +let BaseModel; function getRelaunch (params) { const req = { @@ -23,26 +23,53 @@ function postRelaunch (params) { return $http(req); } +function getStats () { + if (!this.has('GET', 'id')) { + return Promise.reject(new Error('No property, id, exists')); + } + + if (!this.has('GET', 'related.job_events')) { + return Promise.reject(new Error('No related property, job_events, exists')); + } + + const req = { + method: 'GET', + url: `${this.path}${this.get('id')}/job_events/`, + params: { event: 'playbook_on_stats' }, + }; + + return $http(req) + .then(({ data }) => { + if (data.results.length > 0) { + return data.results[0]; + } + + return null; + }); +} + function JobModel (method, resource, config) { - Base.call(this, 'jobs'); + BaseModel.call(this, 'jobs'); this.Constructor = JobModel; + this.postRelaunch = postRelaunch.bind(this); this.getRelaunch = getRelaunch.bind(this); + this.getStats = getStats.bind(this); return this.create(method, resource, config); } -function JobModelLoader (BaseModel, _$http_) { - Base = BaseModel; +function JobModelLoader (_$http_, _BaseModel_) { $http = _$http_; + BaseModel = _BaseModel_; return JobModel; } JobModelLoader.$inject = [ + '$http', 'BaseModel', - '$http' ]; export default JobModelLoader; diff --git a/awx/ui/client/lib/models/JobEvent.js b/awx/ui/client/lib/models/JobEvent.js new file mode 100644 index 0000000000..1c71ba9c54 --- /dev/null +++ b/awx/ui/client/lib/models/JobEvent.js @@ -0,0 +1,19 @@ +let BaseModel; + +function JobEventModel (method, resource, config) { + BaseModel.call(this, 'job_events'); + + this.Constructor = JobEventModel; + + return this.create(method, resource, config); +} + +function JobEventModelLoader (_BaseModel_) { + BaseModel = _BaseModel_; + + return JobEventModel; +} + +JobEventModel.$inject = ['BaseModel']; + +export default JobEventModelLoader; diff --git a/awx/ui/client/lib/models/Organization.js b/awx/ui/client/lib/models/Organization.js index 2e29f72473..6209889fba 100644 --- a/awx/ui/client/lib/models/Organization.js +++ b/awx/ui/client/lib/models/Organization.js @@ -1,21 +1,36 @@ let Base; +let Credential; + +function setDependentResources (id) { + this.dependentResources = [ + { + model: new Credential(), + params: { + organization: id + } + } + ]; +} function OrganizationModel (method, resource, config) { Base.call(this, 'organizations'); this.Constructor = OrganizationModel; + this.setDependentResources = setDependentResources.bind(this); return this.create(method, resource, config); } -function OrganizationModelLoader (BaseModel) { +function OrganizationModelLoader (BaseModel, CredentialModel) { Base = BaseModel; + Credential = CredentialModel; return OrganizationModel; } OrganizationModelLoader.$inject = [ - 'BaseModel' + 'BaseModel', + 'CredentialModel' ]; export default OrganizationModelLoader; diff --git a/awx/ui/client/lib/models/ProjectUpdate.js b/awx/ui/client/lib/models/ProjectUpdate.js new file mode 100644 index 0000000000..df038283cf --- /dev/null +++ b/awx/ui/client/lib/models/ProjectUpdate.js @@ -0,0 +1,51 @@ +let $http; +let BaseModel; + +function getStats () { + if (!this.has('GET', 'id')) { + return Promise.reject(new Error('No property, id, exists')); + } + + if (!this.has('GET', 'related.events')) { + return Promise.reject(new Error('No related property, events, exists')); + } + + const req = { + method: 'GET', + url: `${this.path}${this.get('id')}/events/`, + params: { event: 'playbook_on_stats' }, + }; + + return $http(req) + .then(({ data }) => { + if (data.results.length > 0) { + return data.results[0]; + } + + return null; + }); +} + +function ProjectUpdateModel (method, resource, config) { + BaseModel.call(this, 'project_updates'); + + this.getStats = getStats.bind(this); + + this.Constructor = ProjectUpdateModel; + + return this.create(method, resource, config); +} + +function ProjectUpdateModelLoader (_$http_, _BaseModel_) { + $http = _$http_; + BaseModel = _BaseModel_; + + return ProjectUpdateModel; +} + +ProjectUpdateModelLoader.$inject = [ + '$http', + 'BaseModel' +]; + +export default ProjectUpdateModelLoader; diff --git a/awx/ui/client/lib/models/SystemJob.js b/awx/ui/client/lib/models/SystemJob.js new file mode 100644 index 0000000000..1f1f1c5ee3 --- /dev/null +++ b/awx/ui/client/lib/models/SystemJob.js @@ -0,0 +1,25 @@ +let BaseModel; + +function getStats () { + return Promise.resolve(null); +} + +function SystemJobModel (method, resource, config) { + BaseModel.call(this, 'system_jobs'); + + this.getStats = getStats.bind(this); + + this.Constructor = SystemJobModel; + + return this.create(method, resource, config); +} + +function SystemJobModelLoader (_BaseModel_) { + BaseModel = _BaseModel_; + + return SystemJobModel; +} + +SystemJobModelLoader.$inject = ['BaseModel']; + +export default SystemJobModelLoader; diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js index fb902fb91c..d50c825e22 100644 --- a/awx/ui/client/lib/models/index.js +++ b/awx/ui/client/lib/models/index.js @@ -11,20 +11,25 @@ import InstanceGroup from '~models/InstanceGroup'; import Inventory from '~models/Inventory'; import InventoryScript from '~models/InventoryScript'; import InventorySource from '~models/InventorySource'; +import InventoryUpdate from '~models/InventoryUpdate'; import Job from '~models/Job'; +import JobEvent from '~models/JobEvent'; import JobTemplate from '~models/JobTemplate'; import Me from '~models/Me'; -import ModelsStrings from '~models/models.strings'; import NotificationTemplate from '~models/NotificationTemplate'; import Organization from '~models/Organization'; import Project from '~models/Project'; import Schedule from '~models/Schedule'; +import ProjectUpdate from '~models/ProjectUpdate'; +import SystemJob from '~models/SystemJob'; import UnifiedJobTemplate from '~models/UnifiedJobTemplate'; import WorkflowJob from '~models/WorkflowJob'; import WorkflowJobTemplate from '~models/WorkflowJobTemplate'; import WorkflowJobTemplateNode from '~models/WorkflowJobTemplateNode'; import UnifiedJob from '~models/UnifiedJob'; +import ModelsStrings from '~models/models.strings'; + const MODULE_NAME = 'at.lib.models'; angular @@ -42,18 +47,22 @@ angular .service('InventoryModel', Inventory) .service('InventoryScriptModel', InventoryScript) .service('InventorySourceModel', InventorySource) + .service('InventoryUpdateModel', InventoryUpdate) + .service('JobEventModel', JobEvent) .service('JobModel', Job) .service('JobTemplateModel', JobTemplate) .service('MeModel', Me) - .service('ModelsStrings', ModelsStrings) .service('NotificationTemplate', NotificationTemplate) .service('OrganizationModel', Organization) .service('ProjectModel', Project) .service('ScheduleModel', Schedule) .service('UnifiedJobModel', UnifiedJob) + .service('ProjectUpdateModel', ProjectUpdate) + .service('SystemJobModel', SystemJob) .service('UnifiedJobTemplateModel', UnifiedJobTemplate) .service('WorkflowJobModel', WorkflowJob) .service('WorkflowJobTemplateModel', WorkflowJobTemplate) - .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode); + .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode) + .service('ModelsStrings', ModelsStrings); export default MODULE_NAME; diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index 11192aba29..b024b8ad3e 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -73,9 +73,15 @@ function BaseStringService (namespace) { this.deleteResource = { HEADER: t.s('Delete'), USED_BY: resourceType => t.s('The {{ resourceType }} is currently being used by other resources.', { resourceType }), + UNAVAILABLE: resourceType => t.s('Deleting this {{ resourceType }} will make the following resources unavailable.', { resourceType }), CONFIRM: resourceType => t.s('Are you sure you want to delete this {{ resourceType }}?', { resourceType }) }; + this.cancelJob = { + HEADER: t.s('Cancel'), + SUBMIT_REQUEST: t.s('Are you sure you want to submit the request to cancel this job?') + }; + this.error = { HEADER: t.s('Error!'), CALL: ({ path, action, status }) => t.s('Call to {{ path }} failed. {{ action }} returned status: {{ status }}.', { path, action, status }), diff --git a/awx/ui/client/lib/theme/_global.less b/awx/ui/client/lib/theme/_global.less index 6995b224a5..109e316be5 100644 --- a/awx/ui/client/lib/theme/_global.less +++ b/awx/ui/client/lib/theme/_global.less @@ -13,6 +13,7 @@ &[disabled] { background: @at-color-disabled; + border-color: @at-color-disabled; } } @@ -50,6 +51,17 @@ font-size: @at-font-size-body; } +.at-ButtonIcon-noborder { + padding: 4px @at-padding-button-horizontal; + font-size: @at-font-size-body; + .at-mixin-Button(); + .at-mixin-ButtonHollow( + 'at-color-default', + 'at-color-default', + 'at-color-button-text-default' + ); +} + .at-Button--expand { width: 100%; } diff --git a/awx/ui/client/lib/theme/_utility.less b/awx/ui/client/lib/theme/_utility.less index 1f47a481f3..b3663b74a3 100644 --- a/awx/ui/client/lib/theme/_utility.less +++ b/awx/ui/client/lib/theme/_utility.less @@ -16,3 +16,15 @@ margin-left: 0; margin-right: 0; } + +.at-u-clear { + clear: both; +} + +.at-u-noBorder { + border: none; +} + +.at-u-floatRight { + float: right +} diff --git a/awx/ui/client/lib/theme/_variables.less b/awx/ui/client/lib/theme/_variables.less index 918f67342c..627d775c8e 100644 --- a/awx/ui/client/lib/theme/_variables.less +++ b/awx/ui/client/lib/theme/_variables.less @@ -115,7 +115,7 @@ @at-color-success: @at-green; @at-color-success-hover: @at-green-hover; -@at-color-disabled: @at-gray-d7; +@at-color-disabled: @at-gray-b7; @at-color-body-background-dark: @at-gray-70; @at-color-body-background-light: @at-gray-eb; @@ -138,7 +138,7 @@ @at-color-input-background: @at-gray-fc; @at-color-input-border: @at-gray-b7; -@at-color-input-button: @at-gray-fc; +@at-color-input-button: @at-white; @at-color-input-button-hover: @at-gray-f2; @at-color-input-disabled: @at-gray-eb; @at-color-input-readonly: @at-color-input-background; @@ -174,6 +174,9 @@ @at-color-side-nav-content: @at-white; @at-color-side-nav-item-background-hover: @at-gray-b7; @at-color-side-nav-item-border-hover: @at-white; +@at-color-side-nav-item-icon: @at-white; +@at-color-side-nav-item-spacer: @at-gray-d7; +@at-color-side-nav-space-collapsed-border: @at-gray-b7; @at-color-footer-background: @at-gray-fc; @at-color-footer: @at-gray-70; @@ -208,6 +211,8 @@ @at-font-size-navigation: @at-font-size-3x; @at-font-size-table-heading: @at-font-size-3x; @at-font-size-menu-icon: @at-font-size-5x; +@at-font-size-side-nav-icon: 19px; +@at-font-size-side-nav-space: 11px; @at-font-size-list-row-item-tag: 10px; @at-font-size-list-row-action: 19px; @at-font-size-list-row-action-icon: 19px; @@ -228,6 +233,13 @@ @at-padding-input: @at-space-2x; @at-padding-top-nav-item-sides: @at-space-4x; @at-padding-side-nav-item-icon: @at-space-3x; +@at-padding-side-nav-item-icon: 10px 15px; +@at-padding-side-nav-item-spacer: 10px 10px 25px 15px; +@at-padding-bottom-side-nav-toggle-mobile: 15px; +@at-padding-top-side-nav-toggle: 5px; +@at-padding-left-side-nav-toggle-icon: 15px; +@at-padding-left-side-nav-item-icon: 10px; +@at-padding-left-side-nav-item-icon-expanded: 15px; @at-padding-between-side-nav-icon-text: @at-space-3x; @at-padding-footer-right: @at-space-4x; @at-padding-footer-bottom: @at-space-4x; @@ -246,6 +258,7 @@ @at-margin-form-label-hint: @at-space-2x; @at-margin-top-nav-item-between-icon-and-name: @at-space-2x; @at-margin-top-nav-item-icon-socket-top-makeup: -3px; +@at-margin-side-nav-space-collapsed: 5px 0; @at-margin-after-footer-link: @at-space; @at-margin-footer-top: @at-space-4x; @@ -274,7 +287,7 @@ @at-height-top-nav: 60px; @at-height-top-nav-item-icon: 21px; @at-height-top-nav-item-icon-socket: 18px; -@at-height-side-nav-item-icon: 20px; +@at-height-side-nav-item-icon: 19px; @at-height-side-nav-spacer: 20px; @at-height-top-side-nav-makeup: 55px; @at-height-list-empty: 200px; @@ -282,13 +295,15 @@ @at-height-list-row-item: 27px; @at-height-list-row-item-tag: 15px; @at-height-list-row-action: 30px; +@at-height-side-nav-toggle-mobile: 40px; @at-width-input-button-sm: 72px; @at-width-input-button-md: 84px; @at-width-collapsed-side-nav: 50px; -@at-width-expanded-side-nav: 200px; +@at-width-expanded-side-nav: 180px; @at-width-list-row-item-label: 120px; @at-width-list-row-action: 30px; +@at-width-side-nav-toggle-mobile: 50px; @at-line-height-list-row-item-header: @at-space-3x; @at-line-height-list-row-item-labels: 17px; diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less index 9a9f564840..caf02e2882 100644 --- a/awx/ui/client/lib/theme/index.less +++ b/awx/ui/client/lib/theme/index.less @@ -81,10 +81,6 @@ @import '../../src/inventories-hosts/inventories/inventories.block.less'; @import '../../src/inventories-hosts/shared/associate-groups/associate-groups.block.less'; @import '../../src/inventories-hosts/shared/associate-hosts/associate-hosts.block.less'; -@import '../../src/job-results/host-event/host-event.block.less'; -@import '../../src/job-results/host-status-bar/host-status-bar.block.less'; -@import '../../src/job-results/job-results-stdout/job-results-stdout.block.less'; -@import '../../src/job-results/job-results.block.less'; @import '../../src/job-submission/job-submission.block.less'; @import '../../src/license/license.block.less'; @import '../../src/login/loginModal/thirdPartySignOn/thirdPartySignOn.block.less'; @@ -117,7 +113,7 @@ @import '../../src/shared/text-label'; @import '../../src/shared/upgrade/upgrade.block.less'; @import '../../src/smart-status/smart-status.block.less'; -@import '../../src/standard-out/standard-out.block.less'; +@import '../../src/workflow-results/standard-out.block.less'; @import '../../src/system-tracking/date-picker/date-picker.block.less'; @import '../../src/system-tracking/fact-data-table/fact-data-table.block.less'; @import '../../src/system-tracking/fact-module-filter.block.less'; @@ -130,6 +126,7 @@ @import '../../src/templates/survey-maker/survey-maker.block.less'; @import '../../src/templates/survey-maker/shared/survey-controls.block.less'; @import '../../src/templates/survey-maker/survey-maker.block.less'; +@import '../../src/templates/workflows/workflow.block.less'; @import '../../src/templates/workflows/workflow-chart/workflow-chart.block.less'; @import '../../src/templates/workflows/workflow-controls/workflow-controls.block.less'; @import '../../src/templates/workflows/workflow-maker/workflow-maker.block.less'; diff --git a/awx/ui/client/src/about/about.partial.html b/awx/ui/client/src/about/about.partial.html index 32e32b72e8..3f49a15514 100644 --- a/awx/ui/client/src/about/about.partial.html +++ b/awx/ui/client/src/about/about.partial.html @@ -25,7 +25,7 @@ Ansible {{ ansible_version }}
- Copyright © 2017 Red Hat, Inc.
+ Copyright © 2018 Red Hat, Inc.
Visit
Ansible.com for more information.

diff --git a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js index 8fe870ce81..2b01398280 100644 --- a/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js +++ b/awx/ui/client/src/activity-stream/factories/build-anchor.factory.js @@ -33,7 +33,7 @@ export default function BuildAnchor($log, $filter) { url += 'jobs/' + obj.id; break; case 'inventory': - url += obj.kind && obj.kind === "smart" ? 'inventories/smart/' + obj.id + '/' : 'inventories/inventory' + obj.id + '/'; + url += obj.kind && obj.kind === "smart" ? 'inventories/smart/' + obj.id + '/' : 'inventories/inventory/' + obj.id + '/'; break; case 'schedule': // schedule urls depend on the resource they're associated with diff --git a/awx/ui/client/src/activity-stream/factories/show-detail.factory.js b/awx/ui/client/src/activity-stream/factories/show-detail.factory.js index e275013acc..b2d328aa79 100644 --- a/awx/ui/client/src/activity-stream/factories/show-detail.factory.js +++ b/awx/ui/client/src/activity-stream/factories/show-detail.factory.js @@ -18,6 +18,8 @@ export default scope.user = ((activity.summary_fields.actor) ? activity.summary_fields.actor.username : 'system') + ' on ' + $filter('longDate')(activity.timestamp); scope.operation = activity.description; + scope.settingCategory = _.get(activity, 'summary_fields.setting[0].category'); + scope.settingName = _.get(activity, 'summary_fields.setting[0].name'); scope.header = "Event " + activity.id; scope.variables = ParseVariableString(scope.changes); diff --git a/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.block.less b/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.block.less index de9445942f..3cd0ae8e18 100644 --- a/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.block.less +++ b/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.block.less @@ -13,7 +13,7 @@ } .StreamDetail-inlineRowTitle { - flex: 0 0 110px; + flex: 0 0 125px; } .StreamDetail-inlineRowData { diff --git a/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html b/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html index fb08ec98de..e3f83507f4 100644 --- a/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html +++ b/awx/ui/client/src/activity-stream/streamDetailModal/streamDetailModal.partial.html @@ -16,6 +16,14 @@
ACTION
+
+
SETTING CATEGORY
+
+
+
+
SETTING NAME
+
+
CHANGES
-
- - -
- -
-
-
-
- {{ label }} -
-
-
-
-
- - - - - - - -
-
- - -
-
- - - {{ job.name }} -
-
- -
- -
- Plays -
- - {{ playCount || 0}} - - - -
- Tasks -
- - {{ taskCount || 0}} - - - -
- Hosts -
- - {{ hostCount || 0}} - - - - - - - -
- Elapsed -
- - {{ job.elapsed * 1000 | duration: "hh:mm:ss" }} - -
- - -
- - - - - - - - - -
-
-
- - - - -
- -
- - diff --git a/awx/ui/client/src/job-results/job-results.route.js b/awx/ui/client/src/job-results/job-results.route.js deleted file mode 100644 index 60c06de7cd..0000000000 --- a/awx/ui/client/src/job-results/job-results.route.js +++ /dev/null @@ -1,187 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import {templateUrl} from '../shared/template-url/template-url.factory'; - -const defaultParams = { - page_size: "200", - order_by: 'start_line', - not__event__in: 'playbook_on_start,playbook_on_play_start,playbook_on_task_start,playbook_on_stats' -}; - -export default { - name: 'jobResult', - url: '/jobs/{id: int}', - searchPrefix: 'job_event', - ncyBreadcrumb: { - parent: 'jobs', - label: '{{ job.id }} - {{ job.name }}' - }, - data: { - socket: { - "groups":{ - "jobs": ["status_changed", "summary"], - "job_events": [] - } - } - }, - params: { - job_event_search: { - value: defaultParams, - dynamic: true, - squash: '' - } - }, - resolve: { - statusSocket: ['$rootScope', '$stateParams', function($rootScope, $stateParams) { - var preScope = {}; - var eventOn = $rootScope.$on(`ws-jobs`, function(e, data) { - if (parseInt(data.unified_job_id, 10) === - parseInt($stateParams.id,10)) { - preScope.job_status = data.status; - } - }); - return [preScope, eventOn]; - }], - // the GET for the particular job - jobData: ['jobResultsService', '$stateParams', function(jobResultsService, $stateParams) { - return jobResultsService.getJobData($stateParams.id); - }], - Dataset: ['QuerySet', '$stateParams', 'jobData', - function(qs, $stateParams, jobData) { - let path = jobData.related.job_events; - return qs.search(path, $stateParams[`job_event_search`]); - } - ], - // used to signify if job is completed or still running - jobFinished: ['jobData', function(jobData) { - if (jobData.finished) { - return true; - } else { - return false; - } - }], - // after the GET for the job, this helps us keep the status bar from - // flashing as rest data comes in. If the job is finished and - // there's a playbook_on_stats event, go ahead and resolve the count - // so you don't get that flashing! - count: ['jobData', 'jobResultsService', 'Rest', '$q', '$stateParams', '$state', function(jobData, jobResultsService, Rest, $q, $stateParams, $state) { - var defer = $q.defer(); - if (jobData.finished) { - // if the job is finished, grab the playbook_on_stats - // role to get the final count - Rest.setUrl(jobData.related.job_events + - "?event=playbook_on_stats"); - Rest.get() - .then(({data}) => { - if(!data.results[0]){ - defer.resolve({val: { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }, countFinished: false}); - } - else { - defer.resolve({ - val: jobResultsService - .getCountsFromStatsEvent(data - .results[0].event_data), - countFinished: true}); - } - }) - .catch(() => { - defer.resolve({val: { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }, countFinished: false}); - }); - } else { - // make sure to not include any extra - // search params for a running job (because we can't filter - // incoming job events) - if (!_.isEqual($stateParams.job_event_search, defaultParams)) { - let params = _.cloneDeep($stateParams); - params.job_event_search = defaultParams; - $state.go('.', params, { reload: true }); - } - - // job isn't finished so just send an empty count and read - // from events - defer.resolve({val: { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }, countFinished: false}); - } - return defer.promise; - }], - // GET for the particular jobs labels to be displayed in the - // left-hand pane - jobLabels: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { - var getNext = function(data, arr, resolve) { - Rest.setUrl(data.next); - Rest.get() - .then(({data}) => { - if (data.next) { - getNext(data, arr.concat(data.results), resolve); - } else { - resolve.resolve(arr.concat(data.results) - .map(val => val.name)); - } - }); - }; - - var seeMoreResolve = $q.defer(); - - Rest.setUrl(GetBasePath('jobs') + $stateParams.id + '/labels/'); - Rest.get() - .then(({data}) => { - if (data.next) { - getNext(data, data.results, seeMoreResolve); - } else { - seeMoreResolve.resolve(data.results - .map(val => val.name)); - } - }); - - return seeMoreResolve.promise; - }], - // OPTIONS request for the job. Used to make things like the - // verbosity data in the left-hand pane prettier than just an - // integer - jobDataOptions: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { - Rest.setUrl(GetBasePath('jobs') + $stateParams.id); - var val = $q.defer(); - Rest.options() - .then(function(data) { - val.resolve(data.data); - }, function(data) { - val.reject(data); - }); - return val.promise; - }], - jobExtraCredentials: ['Rest', 'GetBasePath', '$stateParams', '$q', function(Rest, GetBasePath, $stateParams, $q) { - Rest.setUrl(GetBasePath('jobs') + $stateParams.id + '/extra_credentials'); - var val = $q.defer(); - Rest.get() - .then(function(res) { - val.resolve(res.data.results); - }, function(res) { - val.reject(res); - }); - return val.promise; - }] - }, - templateUrl: templateUrl('job-results/job-results'), - controller: 'jobResultsController' -}; diff --git a/awx/ui/client/src/job-results/job-results.service.js b/awx/ui/client/src/job-results/job-results.service.js deleted file mode 100644 index 6b77575fff..0000000000 --- a/awx/ui/client/src/job-results/job-results.service.js +++ /dev/null @@ -1,269 +0,0 @@ -/************************************************* -* Copyright (c) 2016 Ansible, Inc. -* -* All Rights Reserved -*************************************************/ - - -export default ['$q', 'Prompt', '$filter', 'Wait', 'Rest', '$state', 'ProcessErrors', 'GetBasePath', 'Alert', '$rootScope', 'i18n', -function ($q, Prompt, $filter, Wait, Rest, $state, ProcessErrors, GetBasePath, Alert, $rootScope, i18n) { - var val = { - // the playbook_on_stats event returns the count data in a weird format. - // format to what we need! - getCountsFromStatsEvent: function(event_data) { - var hosts = {}, - hostsArr; - - // iterate over the event_data and populate an object with hosts - // and their status data - Object.keys(event_data).forEach(key => { - // failed passes boolean not integer - if (key === "changed" || - key === "dark" || - key === "failures" || - key === "ok" || - key === "skipped") { - // array of hosts from each type ("changed", "dark", etc.) - hostsArr = Object.keys(event_data[key]); - hostsArr.forEach(host => { - if (!hosts[host]) { - // host has not been added to hosts object - // add now - hosts[host] = {}; - } - - if (!hosts[host][key]) { - // host doesn't have key - hosts[host][key] = 0; - } - hosts[host][key] += event_data[key][host]; - }); - } - }); - - var total_hosts_by_state = { - ok: 0, - skipped: 0, - unreachable: 0, - failures: 0, - changed: 0 - }; - - // each host belongs in at most *one* of these states depending on - // the state of its tasks - _.each(hosts, function(host) { - if (host.dark > 0){ - total_hosts_by_state.unreachable++; - } else if (host.failures > 0){ - total_hosts_by_state.failures++; - } else if (host.changed > 0){ - total_hosts_by_state.changed++; - } else if (host.ok > 0){ - total_hosts_by_state.ok++; - } else if (host.skipped > 0){ - total_hosts_by_state.skipped++; - } - }); - - return total_hosts_by_state; - }, - // rest call to grab previously complete job_events - getEvents: function(url) { - var val = $q.defer(); - - Rest.setUrl(url); - Rest.get() - .then(({data}) => { - val.resolve({results: data.results, - next: data.next}); - }) - .catch(({obj, status}) => { - ProcessErrors(null, obj, status, null, { - hdr: 'Error!', - msg: `Could not get job events. - Returned status: ${status}` - }); - val.reject(obj); - }); - - return val.promise; - }, - deleteJob: function(job) { - Prompt({ - hdr: i18n._("Delete Job"), - resourceName: `#${job.id} ` + $filter('sanitize')(job.name), - body: `
- ${i18n._("Are you sure you want to delete this job?")} -
`, - action: function() { - Wait('start'); - Rest.setUrl(job.url); - Rest.destroy() - .then(() => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - $state.go('jobs'); - }) - .catch(({obj, status}) => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - ProcessErrors(null, obj, status, null, { - hdr: 'Error!', - msg: `Could not delete job. - Returned status: ${status}` - }); - }); - }, - actionText: i18n._('DELETE') - }); - }, - cancelJob: function(job) { - var doCancel = function() { - Rest.setUrl(job.url + 'cancel'); - Rest.post({}) - .then(() => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - }) - .catch(({obj, status}) => { - Wait('stop'); - $('#prompt-modal').modal('hide'); - ProcessErrors(null, obj, status, null, { - hdr: 'Error!', - msg: `Could not cancel job. - Returned status: ${status}` - }); - }); - }; - - Prompt({ - hdr: i18n._('Cancel Job'), - resourceName: `#${job.id} ` + $filter('sanitize')(job.name), - body: `
- ${i18n._("Are you sure you want to cancel this job?")} -
`, - action: function() { - Wait('start'); - Rest.setUrl(job.url + 'cancel'); - Rest.get() - .then(({data}) => { - if (data.can_cancel === true) { - doCancel(); - } else { - $('#prompt-modal').modal('hide'); - ProcessErrors(null, data, null, null, { - hdr: 'Error!', - msg: `Job has completed, - unabled to be canceled.` - }); - } - }); - }, - actionText: i18n._('PROCEED') - }); - }, - getJobData: function(id){ - var val = $q.defer(); - - Rest.setUrl(GetBasePath('jobs') + id ); - Rest.get() - .then(function(data) { - val.resolve(data.data); - }, function(data) { - val.reject(data); - - if (data.status === 404) { - Alert('Job Not Found', 'Cannot find job.', 'alert-info'); - } else if (data.status === 403) { - Alert('Insufficient Permissions', 'You do not have permission to view this job.', 'alert-info'); - } - - $state.go('jobs'); - }); - - return val.promise; - }, - // Generate a helper class for job_event statuses - // the stack for which status to display is - // unreachable > failed > changed > ok - // uses the API's runner events and convenience properties .failed .changed to determine status. - // see: job_event_callback.py for more filters to support - processEventStatus: function(event){ - if (event.event === 'runner_on_unreachable'){ - return { - class: 'HostEvent-status--unreachable', - status: 'unreachable' - }; - } - // equiv to 'runner_on_error' && 'runner on failed' - if (event.failed){ - return { - class: 'HostEvent-status--failed', - status: 'failed' - }; - } - // catch the changed case before ok, because both can be true - if (event.changed){ - return { - class: 'HostEvent-status--changed', - status: 'changed' - }; - } - if (event.event === 'runner_on_ok' || event.event === 'runner_on_async_ok'){ - return { - class: 'HostEvent-status--ok', - status: 'ok' - }; - } - if (event.event === 'runner_on_skipped'){ - return { - class: 'HostEvent-status--skipped', - status: 'skipped' - }; - } - }, - // GET events related to a job run - // e.g. - // ?event=playbook_on_stats - // ?parent=206&event__startswith=runner&page_size=200&order=host_name,counter - getRelatedJobEvents: function(id, params){ - var url = GetBasePath('jobs'); - url = url + id + '/job_events/?' + this.stringifyParams(params); - Rest.setUrl(url); - return Rest.get() - .then((response) => { - return response; - }) - .catch(({data, status}) => { - ProcessErrors($rootScope, data, status, null, { hdr: 'Error!', - msg: 'Call to ' + url + '. GET returned: ' + status }); - }); - }, - stringifyParams: function(params){ - return _.reduce(params, (result, value, key) => { - return result + key + '=' + value + '&'; - }, ''); - }, - // the the API passes through Ansible's event_data response - // we need to massage away the verbose & redundant stdout/stderr properties - processJson: function(data){ - // configure fields to ignore - var ignored = [ - 'type', - 'event_data', - 'related', - 'summary_fields', - 'url', - 'ansible_facts', - ]; - // remove ignored properties - var result = _.chain(data).cloneDeep().forEach(function(value, key, collection){ - if (ignored.indexOf(key) > -1){ - delete collection[key]; - } - }).value(); - return result; - } - }; - return val; -}]; diff --git a/awx/ui/client/src/job-results/main.js b/awx/ui/client/src/job-results/main.js deleted file mode 100644 index f0aedc7c43..0000000000 --- a/awx/ui/client/src/job-results/main.js +++ /dev/null @@ -1,27 +0,0 @@ -/************************************************* - * Copyright (c) 2016 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -import hostStatusBar from './host-status-bar/main'; -import jobResultsStdOut from './job-results-stdout/main'; -import hostEvent from './host-event/main'; - -import route from './job-results.route.js'; - -import jobResultsController from './job-results.controller'; - -import jobResultsService from './job-results.service'; -import eventQueueService from './event-queue.service'; -import parseStdoutService from './parse-stdout.service'; - -export default - angular.module('jobResults', [hostStatusBar.name, jobResultsStdOut.name, hostEvent.name, 'angularMoment']) - .run(['$stateExtender', function($stateExtender) { - $stateExtender.addState(route); - }]) - .controller('jobResultsController', jobResultsController) - .service('jobResultsService', jobResultsService) - .service('eventQueue', eventQueueService) - .service('parseStdoutService', parseStdoutService); diff --git a/awx/ui/client/src/job-results/parse-stdout.service.js b/awx/ui/client/src/job-results/parse-stdout.service.js deleted file mode 100644 index 66c969b9c4..0000000000 --- a/awx/ui/client/src/job-results/parse-stdout.service.js +++ /dev/null @@ -1,293 +0,0 @@ -/************************************************* -* Copyright (c) 2016 Ansible, Inc. -* -* All Rights Reserved -*************************************************/ - -export default ['$log', 'moment', 'i18n', function($log, moment, i18n){ - var val = { - // parses stdout string from api and formats various codes to the - // correct dom structure - prettify: function(line, unstyled){ - line = line - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - - // TODO: remove once Chris's fixes to the [K lines comes in - if (line.indexOf("[K") > -1) { - $log.error(line); - } - - if(!unstyled){ - // add span tags with color styling - line = line.replace(/u001b/g, ''); - - // ansi classes - /* jshint ignore:start */ - line = line.replace(/(|)\[1;im/g, ''); - line = line.replace(/(|)\[0;30m/g, ''); - line = line.replace(/(|)\[1;30m/g, ''); - line = line.replace(/(|)\[[0,1];31m/g, ''); - line = line.replace(/(|)\[0;32m(=|)/g, ''); - line = line.replace(/(|)\[0;32m1/g, ''); - line = line.replace(/(|)\[0;33m/g, ''); - line = line.replace(/(|)\[0;34m/g, ''); - line = line.replace(/(|)\[[0,1];35m/g, ''); - line = line.replace(/(|)\[0;36m/g, ''); - line = line.replace(/()\s/g, '$1'); - - //end span - line = line.replace(/(|)\[0m/g, ''); - /* jshint ignore:end */ - } else { - // For the host event modal in the standard out tab, - // the styling isn't necessary - line = line.replace(/u001b/g, ''); - - // ansi classes - /* jshint ignore:start */ - line = line.replace(/(|)\[[0,1];3[0-9]m(1|=|)/g, ''); - line = line.replace(/()\s/g, '$1'); - - //end span - line = line.replace(/(|)\[0m/g, ''); - /* jshint ignore:end */ - } - - return line; - }, - // adds anchor tags and tooltips to host status lines - getAnchorTags: function(event){ - if(event.event_name.indexOf("runner_") === -1){ - return `"`; - } - else{ - return ` JobResultsStdOut-stdoutColumn--clickable" ui-sref="jobResult.host-event.json({eventId: ${event.id}, taskUuid: '${event.event_data.task_uuid}' })" aw-tool-tip="${i18n._("Event ID")}: ${event.id}
${i18n._("Status")}: ${event.event_display}
${i18n._("Click for details")}" data-placement="top"`; - } - - }, - // this adds classes based on event data to the - // .JobResultsStdOut-aLineOfStdOut element - getLineClasses: function(event, line, lineNum) { - var string = ""; - - if (lineNum === event.end_line) { - // used to tell you where to put stuff in the pane - string += ` next_is_${event.end_line + 1}`; - } - - if (event.event_name === "playbook_on_play_start") { - // play header classes - string += " header_play"; - string += " header_play_" + event.event_data.play_uuid; - - // give the actual header class to the line with the - // actual header info (think cowsay) - if (line.indexOf("PLAY") > -1) { - string += " actual_header"; - } - } else if (event.event_name === "playbook_on_task_start") { - // task header classes - string += " header_task"; - string += " header_task_" + event.event_data.task_uuid; - - // give the actual header class to the line with the - // actual header info (think cowsay) - if (line.indexOf("TASK") > -1 || - line.indexOf("RUNNING HANDLER") > -1) { - string += " actual_header"; - } - - // task headers also get classed by their parent play - // if applicable - if (event.event_data.play_uuid) { - string += " play_" + event.event_data.play_uuid; - } - } else if (event.event_name !== "playbook_on_stats"){ - string += " not_skeleton"; - // host status or debug line - - // these get classed by their parent play if applicable - if (event.event_data.play_uuid) { - string += " play_" + event.event_data.play_uuid; - } - // as well as their parent task if applicable - if (event.event_data.task_uuid) { - string += " task_" + event.event_data.task_uuid; - } - } - - // TODO: adding this line_num_XX class is hacky because the - // line number is availabe in children of this dom element - string += " line_num_" + lineNum; - - return string; - }, - getStartTimeBadge: function(event, line){ - // This will return a div with the badge class - // for the start time to show at the right hand - // side of each stdout header line. - // returns an empty string if not a header line - var emptySpan = "", time; - if ((event.event_name === "playbook_on_play_start" || - event.event_name === "playbook_on_task_start") && - line !== "") { - time = moment(event.created).format('HH:mm:ss'); - return `
${time}
`; - } - else if(event.event_name === "playbook_on_stats" && line.indexOf("PLAY") > -1){ - time = moment(event.created).format('HH:mm:ss'); - return `
${time}
`; - } - else { - return emptySpan; - } - - }, - // used to add expand/collapse icon next to line numbers of headers - getCollapseIcon: function(event, line) { - var clickClass, - expanderizerSpecifier; - - var emptySpan = ` -`; - - if ((event.event_name === "playbook_on_play_start" || - event.event_name === "playbook_on_task_start") && - line !== "") { - if (event.event_name === "playbook_on_play_start" && - line.indexOf("PLAY") > -1) { - // play header specific attrs - expanderizerSpecifier = "play"; - clickClass = "play_" + - event.event_data.play_uuid; - } else if (line.indexOf("TASK") > -1 || - line.indexOf("RUNNING HANDLER") > -1) { - // task header specific attrs - expanderizerSpecifier = "task"; - clickClass = "task_" + - event.event_data.task_uuid; - } else { - // header lines that don't have PLAY, TASK, - // or RUNNING HANDLER in them don't get - // expand icon. - // This provides cowsay support. - return emptySpan; - } - - - var expandDom = ` - - - -`; - return expandDom; - } else { - // non-header lines don't get an expander - return emptySpan; - } - }, - distributeColors: function(lines) { - var colorCode; - return lines.map(line => { - - if (colorCode) { - line = colorCode + line; - } - - if (line.indexOf("[0m") === -1) { - if (line.indexOf("[1;31m") > -1) { - colorCode = "[1;31m"; - } else if (line.indexOf("[1;30m") > -1) { - colorCode = "[1;30m"; - } else if (line.indexOf("[0;31m") > -1) { - colorCode = "[0;31m"; - } else if (line.indexOf("[0;32m=") > -1) { - colorCode = "[0;32m="; - } else if (line.indexOf("[0;32m1") > -1) { - colorCode = "[0;32m1"; - } else if (line.indexOf("[0;32m") > -1) { - colorCode = "[0;32m"; - } else if (line.indexOf("[0;33m") > -1) { - colorCode = "[0;33m"; - } else if (line.indexOf("[0;34m") > -1) { - colorCode = "[0;34m"; - } else if (line.indexOf("[0;35m") > -1) { - colorCode = "[0;35m"; - } else if (line.indexOf("[1;35m") > -1) { - colorCode = "[1;35m"; - } else if (line.indexOf("[0;36m") > -1) { - colorCode = "[0;36m"; - } - } else { - colorCode = null; - } - - return line; - }); - }, - getLineArr: function(event) { - let lineNums = _.range(event.start_line + 1, - event.end_line + 1); - - // hack around no-carriage return issues - if (!lineNums.length) { - lineNums = [event.start_line + 1]; - } - - let lines = event.stdout - .replace("\t", " ") - .split("\r\n"); - - if (lineNums.length > lines.length) { - lineNums = lineNums.slice(0, lines.length); - } - - lines = this.distributeColors(lines); - - // hack around no-carriage return issues - if (lineNums.length === lines.length) { - return _.zip(lineNums, lines); - } - - return _.zip(lineNums, lines).slice(0, -1); - }, - actualEndLine: function(event) { - return event.start_line + this.getLineArr(event).length; - }, - // public function that provides the parsed stdout line, given a - // job_event - parseStdout: function(event){ - // this utilizes the start/end lines and stdout blob - // to create an array in the format: - // [ - // [lineNum, lineText], - // [lineNum, lineText], - // ] - var lineArr = this.getLineArr(event); - - // this takes each `[lineNum: lineText]` element and calls the - // relevant helper functions in this service to build the - // parsed line of standard out - lineArr = lineArr - .map(lineArr => { - return ` -
-
${this.getCollapseIcon(event, lineArr[1])}${lineArr[0]}
-
{ Wait('stop'); if($location.path().replace(/^\//, '').split('/')[0] !== 'jobs') { - $state.go('adHocJobStdout', {id: data.id}); + $state.go('jobz', { id: data.id, type: 'command' }); } }) .catch(({data, status}) => { diff --git a/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js deleted file mode 100644 index 01e04a25bc..0000000000 --- a/awx/ui/client/src/job-submission/job-submission-factories/initiateplaybookrun.factory.js +++ /dev/null @@ -1,26 +0,0 @@ -export default - function InitiatePlaybookRun($compile) { - - // This factory drops the submit-job directive into the dom which - // either launches the job (when no user input is needed) or shows - // the user a job sumbission modal with varying steps based on what - // is being prompted/what passwords are needed. - - return function (params) { - var scope = params.scope.$new(), - id = params.id, - relaunch = params.relaunch || false, - job_type = params.job_type, - host_type = params.host_type || ""; - scope.job_template_id = id; - - var el = $compile( "" )( scope ); - $('#content-container').remove('submit-job').append( el ); - }; - } - -InitiatePlaybookRun.$inject = - [ '$compile' - ]; diff --git a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js index 37fd3292b3..2e80cdc369 100644 --- a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js +++ b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js @@ -149,8 +149,8 @@ export default if(base !== 'portal' && Empty(data.system_job) || (base === 'home')){ // use $state.go with reload: true option to re-instantiate sockets in - var goTojobResults = function(state) { - $state.go(state, {id: job}, {reload:true}); + var goTojobResults = function(type) { + $state.go('jobz', {id: job, type}, {reload:true}); }; if($state.includes('jobs')) { @@ -159,23 +159,23 @@ export default else { if(_.has(data, 'job')) { - goTojobResults('jobResult'); + goTojobResults('playbook'); } else if(data.type && data.type === 'workflow_job') { job = data.id; - goTojobResults('workflowResults'); + goTojobResults('workflow_job'); } else if(_.has(data, 'ad_hoc_command')) { - goTojobResults('adHocJobStdout'); + goTojobResults('ad_hoc_command'); } else if(_.has(data, 'system_job')) { - goTojobResults('managementJobStdout'); + goTojobResults('system_job'); } else if(_.has(data, 'project_update')) { // If we are on the projects list or any child state of that list // then we want to stay on that page. Otherwise go to the stdout // view. if(!$state.includes('projects')) { - goTojobResults('scmUpdateStdout'); + goTojobResults('project_update'); } } else if(_.has(data, 'inventory_update')) { @@ -183,7 +183,7 @@ export default // page then we want to stay on that page. Otherwise go to the stdout // view. if(!$state.includes('inventories.edit')) { - goTojobResults('inventorySyncStdout'); + goTojobResults('playbook'); } } } diff --git a/awx/ui/client/src/job-submission/main.js b/awx/ui/client/src/job-submission/main.js index f0a38c6874..563942818b 100644 --- a/awx/ui/client/src/job-submission/main.js +++ b/awx/ui/client/src/job-submission/main.js @@ -4,7 +4,6 @@ * All Rights Reserved *************************************************/ -import InitiatePlaybookRun from './job-submission-factories/initiateplaybookrun.factory'; import LaunchJob from './job-submission-factories/launchjob.factory'; import GetSurveyQuestions from './job-submission-factories/getsurveyquestions.factory'; import AdhocRun from './job-submission-factories/adhoc-run.factory.js'; @@ -21,7 +20,6 @@ import awPasswordMax from './job-submission-directives/aw-password-max.directive export default angular.module('jobSubmission', []) - .factory('InitiatePlaybookRun', InitiatePlaybookRun) .factory('LaunchJob', LaunchJob) .factory('GetSurveyQuestions', GetSurveyQuestions) .factory('AdhocRun', AdhocRun) diff --git a/awx/ui/client/src/management-jobs/card/card.controller.js b/awx/ui/client/src/management-jobs/card/card.controller.js index a66f5d7ec5..846b95a6ff 100644 --- a/awx/ui/client/src/management-jobs/card/card.controller.js +++ b/awx/ui/client/src/management-jobs/card/card.controller.js @@ -30,142 +30,14 @@ export default }); }; getManagementJobs(); - var scope = $rootScope.$new(); - scope.cleanupJob = true; + $scope.cleanupJob = true; // This handles the case where the user refreshes the management job notifications page. if($state.current.name === 'managementJobsList.notifications') { $scope.activeCard = parseInt($state.params.management_id); $scope.cardAction = "notifications"; } - // Cancel - scope.cancelConfigure = function () { - try { - $('#configure-dialog').dialog('close'); - $("#configure-save-button").remove(); - } - catch(e) { - //ignore - } - - Wait('stop'); - }; - - $scope.submitCleanupJob = function(id, name){ - defaultUrl = GetBasePath('system_job_templates')+id+'/launch/'; - CreateDialog({ - id: 'prompt-for-days-facts', - title: name, - scope: scope, - width: 500, - height: 470, - minWidth: 200, - callback: 'PromptForDaysFacts', - resizable: false, - onOpen: function(){ - scope.$watch('prompt_for_days_facts_form.$invalid', function(invalid) { - if (invalid === true) { - $('#prompt-for-days-facts-launch').prop("disabled", true); - } else { - $('#prompt-for-days-facts-launch').prop("disabled", false); - } - }); - - var fieldScope = scope.$parent; - - // set these form elements up on the scope where the form - // is the parent of the current scope - fieldScope.keep_unit_choices = [{ - "label" : "Days", - "value" : "d" - }, - { - "label": "Weeks", - "value" : "w" - }, - { - "label" : "Years", - "value" : "y" - }]; - fieldScope.granularity_keep_unit_choices = [{ - "label" : "Days", - "value" : "d" - }, - { - "label": "Weeks", - "value" : "w" - }, - { - "label" : "Years", - "value" : "y" - }]; - scope.prompt_for_days_facts_form.$setPristine(); - scope.prompt_for_days_facts_form.$invalid = false; - fieldScope.keep_unit = fieldScope.keep_unit_choices[0]; - fieldScope.granularity_keep_unit = fieldScope.granularity_keep_unit_choices[1]; - fieldScope.keep_amount = 30; - fieldScope.granularity_keep_amount = 1; - }, - buttons: [ - { - "label": "Cancel", - "onClick": function() { - $(this).dialog('close'); - }, - "class": "btn btn-default", - "id": "prompt-for-days-facts-cancel" - }, - { - "label": "Launch", - "onClick": function() { - var extra_vars = { - "older_than": scope.keep_amount+scope.keep_unit.value, - "granularity": scope.granularity_keep_amount+scope.granularity_keep_unit.value - }, - data = {}; - data.extra_vars = JSON.stringify(extra_vars); - - Rest.setUrl(defaultUrl); - Rest.post(data) - .then(({data}) => { - Wait('stop'); - $("#prompt-for-days-facts").dialog("close"); - $("#configure-dialog").dialog('close'); - $state.go('managementJobStdout', {id: data.system_job}, {reload:true}); - }) - .catch(({data, status}) => { - let template_id = scope.job_template_id; - template_id = (template_id === undefined) ? "undefined" : i18n.sprintf("%d", template_id); - ProcessErrors(scope, data, status, null, { hdr: i18n._('Error!'), - msg: i18n.sprintf(i18n._('Failed updating job %s with variables. POST returned: %d'), template_id, status) }); - }); - }, - "class": "btn btn-primary", - "id": "prompt-for-days-facts-launch", - } - ] - }); - - if (scope.removePromptForDays) { - scope.removePromptForDays(); - } - scope.removePromptForDays = scope.$on('PromptForDaysFacts', function() { - // $('#configure-dialog').dialog('close'); - $('#prompt-for-days-facts').show(); - $('#prompt-for-days-facts').dialog('open'); - CreateSelect2({ - element: '#keep_unit', - multiple: false - }); - CreateSelect2({ - element: '#granularity_keep_unit', - multiple: false - }); - Wait('stop'); - }); - }; - $scope.goToNotifications = function(card){ $state.transitionTo('managementJobsList.notifications',{ card: card, @@ -179,14 +51,14 @@ export default CreateDialog({ id: 'prompt-for-days' , title: name, - scope: scope, + scope: $scope, width: 500, height: 300, minWidth: 200, callback: 'PromptForDays', resizable: false, onOpen: function(){ - scope.$watch('prompt_for_days_form.$invalid', function(invalid) { + $scope.$watch('prompt_for_days_form.$invalid', function(invalid) { if (invalid === true) { $('#prompt-for-days-launch').prop("disabled", true); } else { @@ -194,10 +66,10 @@ export default } }); - var fieldScope = scope.$parent; + let fieldScope = $scope.$parent; fieldScope.days_to_keep = 30; - scope.prompt_for_days_form.$setPristine(); - scope.prompt_for_days_form.$invalid = false; + $scope.prompt_for_days_form.$setPristine(); + $scope.prompt_for_days_form.$invalid = false; }, buttons: [ { @@ -212,7 +84,7 @@ export default { "label": "Launch", "onClick": function() { - var extra_vars = {"days": scope.days_to_keep }, + const extra_vars = {"days": $scope.days_to_keep }, data = {}; data.extra_vars = JSON.stringify(extra_vars); @@ -222,12 +94,12 @@ export default Wait('stop'); $("#prompt-for-days").dialog("close"); // $("#configure-dialog").dialog('close'); - $state.go('managementJobStdout', {id: data.system_job}, {reload:true}); + $state.go('jobz', { id: data.system_job, type: 'system' }, { reload: true }); }) .catch(({data, status}) => { - let template_id = scope.job_template_id; + let template_id = $scope.job_template_id; template_id = (template_id === undefined) ? "undefined" : i18n.sprintf("%d", template_id); - ProcessErrors(scope, data, status, null, { hdr: i18n._('Error!'), + ProcessErrors($scope, data, status, null, { hdr: i18n._('Error!'), msg: i18n.sprintf(i18n._('Failed updating job %s with variables. POST returned: %d'), template_id, status) }); }); }, @@ -237,10 +109,10 @@ export default ] }); - if (scope.removePromptForDays) { - scope.removePromptForDays(); + if ($scope.removePromptForDays) { + $scope.removePromptForDays(); } - scope.removePromptForDays = scope.$on('PromptForDays', function() { + $scope.removePromptForDays = $scope.$on('PromptForDays', function() { // $('#configure-dialog').dialog('close'); $('#prompt-for-days').show(); $('#prompt-for-days').dialog('open'); @@ -248,15 +120,6 @@ export default }); }; - $scope.chooseRunJob = function(id, name) { - if(this.card.job_type === "cleanup_facts") { - // Run only for 'Cleanup Fact Details' - $scope.submitCleanupJob(id, name); - } else { - $scope.submitJob(id, name); - } - }; - $scope.configureSchedule = function(id) { $state.transitionTo('managementJobsList.schedule', { id: id diff --git a/awx/ui/client/src/management-jobs/card/card.partial.html b/awx/ui/client/src/management-jobs/card/card.partial.html index 66aa4befbe..008786dc7f 100644 --- a/awx/ui/client/src/management-jobs/card/card.partial.html +++ b/awx/ui/client/src/management-jobs/card/card.partial.html @@ -18,7 +18,7 @@

{{ card.name }}

-
\ No newline at end of file + diff --git a/awx/ui/client/src/management-jobs/card/mgmtcards.block.less b/awx/ui/client/src/management-jobs/card/mgmtcards.block.less index 75a463dc82..57c09e84e3 100644 --- a/awx/ui/client/src/management-jobs/card/mgmtcards.block.less +++ b/awx/ui/client/src/management-jobs/card/mgmtcards.block.less @@ -1,7 +1,6 @@ .MgmtCards { display: flex; flex-flow: row wrap; - justify-content: space-between; } .MgmtCards-card { @@ -11,6 +10,7 @@ border: 1px solid @b7grey; align-items: baseline; margin-top: 20px; + margin-right: 20px; width: 32%; } diff --git a/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html index 9723193d39..a735e4c825 100644 --- a/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/management-jobs/scheduler/schedulerForm.partial.html @@ -161,7 +161,7 @@ ng-show="sheduler_frequency_error"> -
+
A value is required.
@@ -521,44 +521,6 @@
- -
- Note: For facts collected older than the time period specified, save one fact scan (snapshot) per time window (frequency). For example, facts older than 30 days are purged, while one weekly fact scan is kept. - Caution: Setting both numerical variables to "0" will delete all facts.
- -
- -
- - -
- - -
-
Please enter the number of days you would like to keep this data.
-
Please enter a valid number.
-
Please enter a non-negative number.
-
Please enter a number smaller than 9999.
-
-
- -
- -
- - -
- - -
-
Please enter the number of days you would like to keep this data.
-
Please enter a valid number.
-
Please enter a non-negative number.
-
Please enter a number smaller than 9999.
-
-
-
{ @@ -40,5 +44,8 @@ } else { $scope.item = data; } + if ($scope.codeMirror.init) { + $scope.codeMirror.init($scope.item.variables); + } }); }]; diff --git a/awx/ui/client/src/network-ui/network-details/details.partial.html b/awx/ui/client/src/network-ui/network-details/details.partial.html index 5b52ae1e6a..f7771e8916 100644 --- a/awx/ui/client/src/network-ui/network-details/details.partial.html +++ b/awx/ui/client/src/network-ui/network-details/details.partial.html @@ -31,7 +31,12 @@
- + +
diff --git a/awx/ui/client/src/network-ui/network-details/main.js b/awx/ui/client/src/network-ui/network-details/main.js index 1827ce7297..ffa06f41e4 100644 --- a/awx/ui/client/src/network-ui/network-details/main.js +++ b/awx/ui/client/src/network-ui/network-details/main.js @@ -5,9 +5,7 @@ *************************************************/ import awxNetDetailsPanel from './details.directive'; -import awxNetExtraVars from './network-extra-vars/network-extra-vars.directive'; export default angular.module('networkDetailsDirective', []) - .directive('awxNetDetailsPanel', awxNetDetailsPanel) - .directive('awxNetExtraVars', awxNetExtraVars); + .directive('awxNetDetailsPanel', awxNetDetailsPanel); diff --git a/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.block.less b/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.block.less deleted file mode 100644 index 9e340cccec..0000000000 --- a/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.block.less +++ /dev/null @@ -1,168 +0,0 @@ -.NetworkingExtraVarsLabel{ - display: flex; - width: 100%; -} - -.NetworkingExtraVars-extraVarsLabelContainer{ - flex: 1 0 auto; -} - -.NetworkingExtraVars-expandTextContainer{ - flex: 1 0 auto; - text-align: right; - font-weight: normal; - color: @default-link; - cursor: pointer; - font-size: 12px; -} - -.noselect { - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Chrome/Safari/Opera */ - -khtml-user-select: none; /* Konqueror */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently - not supported by any browser */ -} - -@media screen and (min-width: 768px){ - .NetworkingExtraVars .modal-dialog{ - width: 700px; - } -} - -.NetworkingExtraVarsModal .modal-dialog{ - width: calc(~"100% - 200px"); - height: calc(~"100vh - 80px"); -} - -.NetworkingExtraVarsModal .modal-content{ - height: 100%; -} - -.NetworkingExtraVars .CodeMirror{ - overflow-x: hidden; -} - -.NetworkingExtraVars-close:hover{ - color: @btn-txt; - background-color: @btn-bg-hov; -} - -.NetworkingExtraVars-body{ - margin-bottom: 20px; -} - -.NetworkingExtraVars-tab:hover { - color: @btn-txt; - background-color: @btn-bg-hov; - cursor: pointer; -} -.NetworkingExtraVars-tab--selected{ - color: @btn-txt-sel!important; - background-color: @default-icon!important; - border-color: @default-icon!important; -} -.NetworkingExtraVars-view--container{ - width: 100%; - display: flex; - flex-direction: row; - flex-wrap: nowrap; - justify-content: space-between; -} -.NetworkingExtraVars .modal-footer{ - border: 0; - margin-top: 0px; - padding-top: 5px; -} -.NetworkingExtraVars-controls{ - float: right; - margin-top: 15px; - button { - margin-left: 10px; - } -} - -.NetworkingExtraVars-header{ - padding-bottom: 15px; -} -.NetworkingExtraVars-title{ - color: @default-interface-txt; - font-weight: 600; - margin-bottom: 8px; -} -.NetworkingExtraVarsModal .modal-body{ - padding: 0px!important; - overflow-y: auto; -} -.NetworkingExtraVars-nav{ - padding-top: 12px; - padding-bottom: 20px; -} -.NetworkingExtraVars-field{ - margin-bottom: 8px; - flex: 0 1 12em; -} -.NetworkingExtraVars-field--label{ - text-transform: uppercase; - flex: 0 1 80px; - max-width: 80px; - min-width: 80px; - font-size: 12px; - word-wrap: break-word; -} -.NetworkingExtraVars-field{ - .OnePlusTwo-left--detailsRow; -} -.NetworkingExtraVars-field--content{ - word-wrap: break-word; -} -.NetworkingExtraVars-field--monospaceContent{ - font-family: monospace; -} -.NetworkingExtraVars-button:disabled { - pointer-events: all!important; -} - -.NetworkingExtraVars-numberColumnPreload { - background-color: @default-list-header-bg; - height: 198px; - border-right: 1px solid #ccc; - width: 30px; - position: fixed; -} - -.NetworkingExtraVars-numberColumn { - background-color: @default-list-header-bg; - border-right: 1px solid #ccc; - border-bottom-left-radius: 5px; - color: #999; - font-family: Monaco, Menlo, Consolas, "Courier New", monospace; - position: fixed; - padding: 4px 3px 0 5px; - text-align: right; - white-space: nowrap; - width: 30px; -} - -.NetworkingExtraVars-numberColumn--second{ - padding-top:0px; -} - -.NetworkingExtraVars-noJson{ - align-items: center; - background-color: @default-no-items-bord; - border: 1px solid @default-icon-hov; - border-radius: 5px; - color: @b7grey; - display: flex; - height: 200px; - justify-content: center; - text-transform: uppercase; - width: 100%; -} - -.NetworkingExtraVarsModal .CodeMirror{ - max-height: none; -} diff --git a/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.directive.js b/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.directive.js deleted file mode 100644 index 35cf89539d..0000000000 --- a/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.directive.js +++ /dev/null @@ -1,70 +0,0 @@ -/************************************************* - * Copyright (c) 2018 Ansible, Inc. - * - * All Rights Reserved - *************************************************/ - -const templateUrl = require('~network-ui/network-details/network-extra-vars/network-extra-vars.partial.html'); - -export default [ 'ParseTypeChange', 'ParseVariableString', - function(ParseTypeChange, ParseVariableString) { - return { - scope:{ - item: "=" - }, - templateUrl, - restrict: 'E', - link(scope){ - scope.networkingExtraVarsModalOpen = true; - function init(){ - if(scope.item && scope.item.host_id){ - scope.variables = ParseVariableString(scope.item.variables); - scope.parseType = 'yaml'; - ParseTypeChange({ - scope: scope, - field_id: 'network_host_variables', - variable: 'variables', - readOnly: true - }); - } - } - - scope.$watch('item', function(){ - init(); - }); - - scope.closeExtraVarModal = function() { - // Unbind the listener so it doesn't fire when we close the modal via navigation - $('.CodeMirror')[1].remove(); - $('#NetworkingExtraVarsModal').off('hidden.bs.modal'); - $('#NetworkingExtraVarsModal').modal('hide'); - scope.networkingExtraVarsModalOpen = false; - }; - - scope.openExtraVarsModal = function(){ - scope.networkingExtraVarsModalOpen = true; - $('#NetworkingExtraVarsModal').modal('show'); - - $('.modal-dialog').on('resize', function(){ - resize(); - }); - scope.extra_variables = ParseVariableString(_.cloneDeep(scope.item.variables)); - scope.parseType = 'yaml'; - ParseTypeChange({ - scope: scope, - field_id: 'NetworkingExtraVars-codemirror', - variable: 'extra_variables', - readOnly: true - }); - resize(); - }; - - function resize(){ - let editor = $('.CodeMirror')[1].CodeMirror; - let height = $('#NetworkingExtraVarsModalDialog').height() - $('.NetworkingExtraVars-header').height() - $('.NetworkingExtraVars-controls').height() - 110; - editor.setSize("100%", height); - } - - } - }; -}]; diff --git a/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.partial.html b/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.partial.html deleted file mode 100644 index 1ac026747e..0000000000 --- a/awx/ui/client/src/network-ui/network-details/network-extra-vars/network-extra-vars.partial.html +++ /dev/null @@ -1,72 +0,0 @@ - - - - - diff --git a/awx/ui/client/src/network-ui/network.ui.app.js b/awx/ui/client/src/network-ui/network.ui.app.js index bc71fd4a3e..7fb414950d 100644 --- a/awx/ui/client/src/network-ui/network.ui.app.js +++ b/awx/ui/client/src/network-ui/network.ui.app.js @@ -5,7 +5,6 @@ import networkDetailsDirective from './network-details/main'; import networkZoomWidget from './zoom-widget/main'; //console.log = function () { }; -var angular = require('angular'); var NetworkUIController = require('./network.ui.controller.js'); var cursor = require('./cursor.directive.js'); var router = require('./router.directive.js'); @@ -21,7 +20,8 @@ var debug = require('./debug.directive.js'); var test_results = require('./test_results.directive.js'); var awxNetworkUI = require('./network.ui.directive.js'); -var networkUI = angular.module('networkUI', [ +export default + angular.module('networkUI', [ 'monospaced.mousewheel', atFeaturesNetworking, networkDetailsDirective.name, @@ -41,5 +41,3 @@ var networkUI = angular.module('networkUI', [ .directive('awxNetInventoryToolbox', inventoryToolbox.inventoryToolbox) .directive('awxNetTestResults', test_results.test_results) .directive('awxNetworkUi', awxNetworkUI.awxNetworkUI); - -exports.networkUI = networkUI; diff --git a/awx/ui/client/src/network-ui/network.ui.controller.js b/awx/ui/client/src/network-ui/network.ui.controller.js index de30650caa..a0875b0d64 100644 --- a/awx/ui/client/src/network-ui/network.ui.controller.js +++ b/awx/ui/client/src/network-ui/network.ui.controller.js @@ -1,5 +1,4 @@ /* Copyright (c) 2017 Red Hat, Inc. */ -var angular = require('angular'); var fsm = require('./fsm.js'); var mode_fsm = require('./mode.fsm.js'); var hotkeys = require('./hotkeys.fsm.js'); diff --git a/awx/ui/client/src/network-ui/style.less b/awx/ui/client/src/network-ui/style.less index 0eddbaa9e7..b49b66c8f8 100644 --- a/awx/ui/client/src/network-ui/style.less +++ b/awx/ui/client/src/network-ui/style.less @@ -1,7 +1,6 @@ /* Copyright (c) 2017 Red Hat, Inc. */ @import 'network-nav/network.nav.block.less'; @import 'network-details/details.block.less'; -@import 'network-details/network-extra-vars/network-extra-vars.block.less'; @import 'zoom-widget/zoom.block.less'; @font-face { diff --git a/awx/ui/client/src/network-ui/tower.app.js b/awx/ui/client/src/network-ui/tower.app.js deleted file mode 100644 index 200d5078ce..0000000000 --- a/awx/ui/client/src/network-ui/tower.app.js +++ /dev/null @@ -1,8 +0,0 @@ -/* Copyright (c) 2017 Red Hat, Inc. */ - -var angular = require('angular'); - -var tower = angular.module('tower', ['networkUI', 'ui.router']); - -exports.tower = tower; - diff --git a/awx/ui/client/src/notifications/notificationTemplates.list.js b/awx/ui/client/src/notifications/notificationTemplates.list.js index e8bf90ffa6..0e7182be9a 100644 --- a/awx/ui/client/src/notifications/notificationTemplates.list.js +++ b/awx/ui/client/src/notifications/notificationTemplates.list.js @@ -83,7 +83,7 @@ export default ['i18n', function(i18n){ "class": 'btn-danger btn-xs', awToolTip: i18n._('Copy notification'), dataPlacement: 'top', - ngShow: 'notification_template.summary_fields.user_capabilities.edit' + ngShow: 'notification_template.summary_fields.user_capabilities.copy' }, view: { ngClick: "editNotification(notification_template.id)", diff --git a/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js b/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js index 0e74eb5132..b26fffb264 100644 --- a/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js +++ b/awx/ui/client/src/organizations/linkout/controllers/organizations-inventories.controller.js @@ -234,7 +234,7 @@ export default ['$scope', '$rootScope', '$location', $scope.viewJob = function(url) { // Pull the id out of the URL var id = url.replace(/^\//, '').split('/')[3]; - $state.go('inventorySyncStdout', { id: id }); + $state.go('jobz', { id: id, type: 'inventory' }); }; diff --git a/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js b/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js index 3caeb4425b..2d5e0eab40 100644 --- a/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js +++ b/awx/ui/client/src/organizations/linkout/controllers/organizations-job-templates.controller.js @@ -6,11 +6,11 @@ export default ['$scope', '$rootScope', '$stateParams', 'Rest', 'ProcessErrors', - 'GetBasePath', 'InitiatePlaybookRun', 'Wait', + 'GetBasePath', 'Wait', '$state', 'OrgJobTemplateList', 'OrgJobTemplateDataset', 'QuerySet', function($scope, $rootScope, $stateParams, Rest, ProcessErrors, - GetBasePath, InitiatePlaybookRun, Wait, + GetBasePath, Wait, $state, OrgJobTemplateList, Dataset, qs) { var list = OrgJobTemplateList, @@ -73,10 +73,6 @@ export default ['$scope', '$rootScope', $state.go('templates.editJobTemplate', { job_template_id: id }); }; - $scope.submitJob = function(id) { - InitiatePlaybookRun({ scope: $scope, id: id, job_type: 'job_template' }); - }; - $scope.scheduleJob = function(id) { $state.go('jobTemplateSchedules', { id: id }); }; diff --git a/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js b/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js index 39c322e183..853534f142 100644 --- a/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js +++ b/awx/ui/client/src/organizations/linkout/controllers/organizations-projects.controller.js @@ -187,7 +187,7 @@ export default ['$scope', '$rootScope', '$log', '$stateParams', 'Rest', 'Alert', // Grab the id from summary_fields var id = (data.summary_fields.current_update) ? data.summary_fields.current_update.id : data.summary_fields.last_update.id; - $state.go('scmUpdateStdout', { id: id }); + $state.go('jobz', { id: id, type: 'project' }); } else { Alert('No Updates Available', 'There is no SCM update information available for this project. An update has not yet been ' + diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js index 14ece5f287..20f8b6a6c3 100644 --- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js +++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js @@ -4,15 +4,16 @@ * All Rights Reserved *************************************************/ +import OrganizationsJobTemplatesRoute from '~features/templates/routes/organizationsTemplatesList.route'; + import OrganizationsAdmins from './controllers/organizations-admins.controller'; import OrganizationsInventories from './controllers/organizations-inventories.controller'; -import OrganizationsJobTemplates from './controllers/organizations-job-templates.controller'; import OrganizationsProjects from './controllers/organizations-projects.controller'; import OrganizationsTeams from './controllers/organizations-teams.controller'; import OrganizationsUsers from './controllers/organizations-users.controller'; import { N_ } from '../../i18n'; -export default [{ +let lists = [{ name: 'organizations.users', url: '/:organization_id/users', searchPrefix: 'user', @@ -215,79 +216,6 @@ export default [{ } ] } -}, { - name: 'organizations.job_templates', - url: '/:organization_id/job_templates', - searchPrefix: 'job_template', - views: { - 'form': { - controller: OrganizationsJobTemplates, - templateProvider: function(OrgJobTemplateList, generateList) { - let html = generateList.build({ - list: OrgJobTemplateList, - mode: 'edit', - cancelButton: true - }); - return generateList.wrapPanel(html); - }, - }, - }, - params: { - template_search: { - value: { - or__project__organization: null, - or__inventory__organization: null, - page_size: 20 - }, - dynamic: true - } - }, - data: { - activityStream: true, - activityStreamTarget: 'organization', - socket: { - "groups": { - "jobs": ["status_changed"] - } - } - }, - ncyBreadcrumb: { - parent: "organizations.edit", - label: N_("JOB TEMPLATES") - }, - resolve: { - features: ['FeaturesService', function(FeaturesService) { - return FeaturesService.get(); - }], - OrgJobTemplateList: ['TemplateList', 'GetBasePath', '$stateParams', function(TemplateList) { - let list = _.cloneDeep(TemplateList); - delete list.actions; - // @issue Why is the delete action unavailable in this view? - delete list.fieldActions.delete; - delete list.fields.type; - list.listTitle = N_('Job Templates') + ` | {{ name }}`; - list.emptyListText = "This list is populated by job templates added from the Job Templates section"; - list.iterator = 'template'; - list.name = 'job_templates'; - list.basePath = "job_templates"; - list.fields.smart_status.ngInclude = "'/static/partials/organizations-job-template-smart-status.html'"; - list.fields.name.ngHref = '#/templates/job_template/{{template.id}}'; - list.fieldActions.submit.ngClick = 'submitJob(template.id)'; - list.fieldActions.schedule.ngClick = 'scheduleJob(template.id)'; - list.fieldActions.copy.ngClick = 'copyTemplate(template.id)'; - list.fieldActions.edit.ngClick = "editJobTemplate(template.id)"; - list.fieldActions.view.ngClick = "editJobTemplate(template.id)"; - return list; - }], - OrgJobTemplateDataset: ['OrgJobTemplateList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = GetBasePath(list.name); - $stateParams.template_search.or__project__organization = $stateParams.organization_id; - $stateParams.template_search.or__inventory__organization = $stateParams.organization_id; - return qs.search(path, $stateParams.template_search); - } - ] - } }, { name: 'organizations.admins', url: '/:organization_id/admins', @@ -356,3 +284,7 @@ export default [{ }] } }]; + +lists.push(OrganizationsJobTemplatesRoute); + +export default lists; diff --git a/awx/ui/client/src/organizations/list/organizations-list.controller.js b/awx/ui/client/src/organizations/list/organizations-list.controller.js index 7b3ff2488e..48a61b0397 100644 --- a/awx/ui/client/src/organizations/list/organizations-list.controller.js +++ b/awx/ui/client/src/organizations/list/organizations-list.controller.js @@ -6,39 +6,40 @@ export default ['$stateParams', '$scope', '$rootScope', - 'Rest', 'OrganizationList', 'Prompt', - 'ProcessErrors', 'GetBasePath', 'Wait', '$state', 'rbacUiControlService', '$filter', 'Dataset', 'i18n', + 'Rest', 'OrganizationList', 'Prompt', 'OrganizationModel', + 'ProcessErrors', 'GetBasePath', 'Wait', '$state', + 'rbacUiControlService', '$filter', 'Dataset', 'i18n', + 'AppStrings', function($stateParams, $scope, $rootScope, - Rest, OrganizationList, Prompt, - ProcessErrors, GetBasePath, Wait, $state, rbacUiControlService, $filter, Dataset, i18n) { + Rest, OrganizationList, Prompt, Organization, + ProcessErrors, GetBasePath, Wait, $state, + rbacUiControlService, $filter, Dataset, i18n, + AppStrings + ) { var defaultUrl = GetBasePath('organizations'), list = OrganizationList; - init(); + $scope.canAdd = false; - function init() { - $scope.canAdd = false; + rbacUiControlService.canAdd("organizations") + .then(function(params) { + $scope.canAdd = params.canAdd; + }); + $scope.orgCount = Dataset.data.count; - rbacUiControlService.canAdd("organizations") - .then(function(params) { - $scope.canAdd = params.canAdd; - }); - $scope.orgCount = Dataset.data.count; + // search init + $scope.list = list; + $scope[`${list.iterator}_dataset`] = Dataset.data; + $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - // search init - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; + $scope.orgCards = parseCardData($scope[list.name]); + $rootScope.flashMessage = null; - $scope.orgCards = parseCardData($scope[list.name]); - $rootScope.flashMessage = null; - - // grab the pagination elements, move, destroy list generator elements - $('#organization-pagination').appendTo('#OrgCards'); - $('#organizations tag-search').appendTo('.OrgCards-search'); - $('#organizations-list').remove(); - } + // grab the pagination elements, move, destroy list generator elements + $('#organization-pagination').appendTo('#OrgCards'); + $('#organizations tag-search').appendTo('.OrgCards-search'); + $('#organizations-list').remove(); function parseCardData(cards) { return cards.map(function(card) { @@ -167,13 +168,34 @@ export default ['$stateParams', '$scope', '$rootScope', }); }; - Prompt({ - hdr: i18n._('Delete'), - resourceName: $filter('sanitize')(name), - body: '
' + i18n._('Are you sure you want to delete this organization? This makes everything in this organization unavailable.') + '
', - action: action, - actionText: i18n._('DELETE') - }); + const organization = new Organization(); + + organization.getDependentResourceCounts(id) + .then((counts) => { + const invalidateRelatedLines = []; + let deleteModalBody = `
${AppStrings.get('deleteResource.CONFIRM', 'organization')}
`; + + counts.forEach(countObj => { + if(countObj.count && countObj.count > 0) { + invalidateRelatedLines.push(`
${countObj.label}${countObj.count}
`); + } + }); + + if (invalidateRelatedLines && invalidateRelatedLines.length > 0) { + deleteModalBody = `
${AppStrings.get('deleteResource.UNAVAILABLE', 'organization')} ${AppStrings.get('deleteResource.CONFIRM', 'organization')}
`; + invalidateRelatedLines.forEach(invalidateRelatedLine => { + deleteModalBody += invalidateRelatedLine; + }); + } + + Prompt({ + hdr: i18n._('Delete'), + resourceName: $filter('sanitize')(name), + body: deleteModalBody, + action: action, + actionText: i18n._('DELETE') + }); + }); }; } ]; diff --git a/awx/ui/client/src/organizations/organizations.form.js b/awx/ui/client/src/organizations/organizations.form.js index 14883a3428..f33dd00526 100644 --- a/awx/ui/client/src/organizations/organizations.form.js +++ b/awx/ui/client/src/organizations/organizations.form.js @@ -52,7 +52,8 @@ export default ['NotificationsList', 'i18n', dataTitle: i18n._('Ansible Environment'), dataContainer: 'body', dataPlacement: 'right', - ngDisabled: '!(organization_obj.summary_fields.user_capabilities.edit || canAdd)' + ngDisabled: '!(organization_obj.summary_fields.user_capabilities.edit || canAdd)', + ngShow: 'custom_virtualenvs_options.length > 0' } }, diff --git a/awx/ui/client/src/portal-mode/portal-mode.route.js b/awx/ui/client/src/portal-mode/portal-mode.route.js index cb347821b3..8812a489d6 100644 --- a/awx/ui/client/src/portal-mode/portal-mode.route.js +++ b/awx/ui/client/src/portal-mode/portal-mode.route.js @@ -50,7 +50,7 @@ export default { list: PortalJobTemplateList, mode: 'edit' }); - return html + ''; + return html + ''; }, controller: PortalModeJobTemplatesController } diff --git a/awx/ui/client/src/projects/list/projects-list.controller.js b/awx/ui/client/src/projects/list/projects-list.controller.js index ec21e0009c..2c9525de26 100644 --- a/awx/ui/client/src/projects/list/projects-list.controller.js +++ b/awx/ui/client/src/projects/list/projects-list.controller.js @@ -146,7 +146,7 @@ export default ['$scope', '$rootScope', '$log', 'Rest', 'Alert', // Grab the id from summary_fields var id = (data.summary_fields.current_update) ? data.summary_fields.current_update.id : data.summary_fields.last_update.id; - $state.go('scmUpdateStdout', { id: id }); + $state.go('jobz', { id: id, type: 'project'}, { reload: true }); } else { Alert(i18n._('No Updates Available'), i18n._('There is no SCM update information available for this project. An update has not yet been ' + @@ -241,7 +241,7 @@ export default ['$scope', '$rootScope', '$log', 'Rest', 'Alert', resourceName: $filter('sanitize')(name), body: deleteModalBody, action: action, - actionText: 'DELETE' + actionText: i18n._('DELETE') }); }); }; diff --git a/awx/ui/client/src/projects/main.js b/awx/ui/client/src/projects/main.js index 94eee1de34..8f186e5458 100644 --- a/awx/ui/client/src/projects/main.js +++ b/awx/ui/client/src/projects/main.js @@ -13,7 +13,8 @@ import { N_ } from '../i18n'; import GetProjectPath from './factories/get-project-path.factory'; import GetProjectIcon from './factories/get-project-icon.factory'; import GetProjectToolTip from './factories/get-project-tool-tip.factory'; -import ProjectsTemplatesRoute from './projects-templates.route'; + +import ProjectsTemplatesRoute from '~features/templates/routes/projectsTemplatesList.route'; import ProjectsStrings from './projects.strings'; export default diff --git a/awx/ui/client/src/projects/projects-templates.route.js b/awx/ui/client/src/projects/projects-templates.route.js deleted file mode 100644 index f15c229edd..0000000000 --- a/awx/ui/client/src/projects/projects-templates.route.js +++ /dev/null @@ -1,60 +0,0 @@ -import { N_ } from '../i18n'; - -export default { - url: "/templates", - name: 'projects.edit.templates', - params: { - template_search: { - value: { - page_size: '20', - project: '', - order_by: "-id" - } - } - }, - ncyBreadcrumb: { - label: N_("JOB TEMPLATES") - }, - views: { - // TODO: this controller was removed and replaced - // with the new features/templates controller - // this view should be updated with the new - // expanded list - 'related': { - templateProvider: function(FormDefinition, GenerateForm) { - let html = GenerateForm.buildCollection({ - mode: 'edit', - related: 'templates', - form: typeof(FormDefinition) === 'function' ? - FormDefinition() : FormDefinition - }); - return html; - }, - controller: 'TemplatesListController' - } - }, - resolve: { - ListDefinition: ['TemplateList', '$transition$', (TemplateList, $transition$) => { - let id = $transition$.params().project_id; - TemplateList.actions.add.ngClick = `$state.go('templates.addJobTemplate', {project_id: ${id}})`; - TemplateList.basePath = 'job_templates'; - return TemplateList; - }], - Dataset: ['ListDefinition', 'QuerySet', '$stateParams', 'GetBasePath', '$interpolate', '$rootScope', - (list, qs, $stateParams, GetBasePath, $interpolate, $rootScope) => { - // allow related list definitions to use interpolated $rootScope / $stateParams in basePath field - let path, interpolator; - if (GetBasePath(list.basePath)) { - path = GetBasePath(list.basePath); - } else { - interpolator = $interpolate(list.basePath); - path = interpolator({ $rootScope: $rootScope, $stateParams: $stateParams }); - } - let project_id = $stateParams.project_id; - $stateParams[`${list.iterator}_search`].project = project_id; - path = GetBasePath('job_templates'); - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } -}; diff --git a/awx/ui/client/src/projects/projects.form.js b/awx/ui/client/src/projects/projects.form.js index 38c151215d..baa204bcee 100644 --- a/awx/ui/client/src/projects/projects.form.js +++ b/awx/ui/client/src/projects/projects.form.js @@ -52,17 +52,6 @@ export default ['i18n', 'NotificationsList', 'TemplateList', ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd) || !canEditOrg', awLookupWhen: '(project_obj.summary_fields.user_capabilities.edit || canAdd) && canEditOrg' }, - custom_virtualenv: { - label: i18n._('Ansible Environment'), - type: 'select', - defaultText: i18n._('Select Ansible Environment'), - ngOptions: 'venv for venv in custom_virtualenvs_options track by venv', - awPopOver: "

" + i18n._("Select the custom Python virtual environment for this project to run on.") + "

", - dataTitle: i18n._('Ansible Environment'), - dataContainer: 'body', - dataPlacement: 'right', - ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)' - }, scm_type: { label: i18n._('SCM Type'), type: 'select', @@ -211,8 +200,21 @@ export default ['i18n', 'NotificationsList', 'TemplateList', dataTitle: i18n._('Cache Timeout'), dataPlacement: 'right', dataContainer: "body", - ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)' - } + ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)', + subForm: 'sourceSubForm' + }, + custom_virtualenv: { + label: i18n._('Ansible Environment'), + type: 'select', + defaultText: i18n._('Select Ansible Environment'), + ngOptions: 'venv for venv in custom_virtualenvs_options track by venv', + awPopOver: "

" + i18n._("Select the custom Python virtual environment for this project to run on.") + "

", + dataTitle: i18n._('Ansible Environment'), + dataContainer: 'body', + dataPlacement: 'right', + ngDisabled: '!(project_obj.summary_fields.user_capabilities.edit || canAdd)', + ngShow: 'custom_virtualenvs_options.length > 0' + }, }, buttons: { diff --git a/awx/ui/client/src/projects/projects.list.js b/awx/ui/client/src/projects/projects.list.js index b05d923a8b..f4d504304c 100644 --- a/awx/ui/client/src/projects/projects.list.js +++ b/awx/ui/client/src/projects/projects.list.js @@ -106,7 +106,7 @@ export default ['i18n', function(i18n) { "class": 'btn-danger btn-xs', awToolTip: i18n._('Copy project'), dataPlacement: 'top', - ngShow: 'project.summary_fields.user_capabilities.edit' + ngShow: 'project.summary_fields.user_capabilities.copy' }, edit: { ngClick: "editProject(project.id)", diff --git a/awx/ui/client/src/scheduler/factories/schedule-post.factory.js b/awx/ui/client/src/scheduler/factories/schedule-post.factory.js index bea921a8be..619d2670a9 100644 --- a/awx/ui/client/src/scheduler/factories/schedule-post.factory.js +++ b/awx/ui/client/src/scheduler/factories/schedule-post.factory.js @@ -18,13 +18,7 @@ export default scheduleData.rrule = RRuleToAPI(rrule.toString(), scope); scheduleData.description = (/error/.test(rrule.toText())) ? '' : rrule.toText(); - if (scope.isFactCleanup) { - extra_vars = { - "older_than": scope.scheduler_form.keep_amount.$viewValue + scope.scheduler_form.keep_unit.$viewValue.value, - "granularity": scope.scheduler_form.granularity_keep_amount.$viewValue + scope.scheduler_form.granularity_keep_unit.$viewValue.value - }; - scheduleData.extra_data = JSON.stringify(extra_vars); - } else if (scope.cleanupJob) { + if (scope.cleanupJob) { extra_vars = { "days" : scope.scheduler_form.schedulerPurgeDays.$viewValue }; diff --git a/awx/ui/client/src/scheduler/scheduleToggle.block.less b/awx/ui/client/src/scheduler/scheduleToggle.block.less index 49bcc954f7..b234a0b5f2 100644 --- a/awx/ui/client/src/scheduler/scheduleToggle.block.less +++ b/awx/ui/client/src/scheduler/scheduleToggle.block.less @@ -20,7 +20,8 @@ &.ScheduleToggle--disabled { cursor: not-allowed; - border-color: @default-link !important; + background-color: none; + border-color: @d7grey !important; .ScheduleToggle-switch { background-color: @d7grey !important; cursor: not-allowed; diff --git a/awx/ui/client/src/scheduler/schedulerAdd.controller.js b/awx/ui/client/src/scheduler/schedulerAdd.controller.js index ec6cb4862d..49e143fa1a 100644 --- a/awx/ui/client/src/scheduler/schedulerAdd.controller.js +++ b/awx/ui/client/src/scheduler/schedulerAdd.controller.js @@ -8,10 +8,13 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', 'GetBasePath', 'Rest', 'ParentObject', 'JobTemplateModel', '$q', 'Empty', 'SchedulePost', 'ProcessErrors', 'SchedulerInit', '$location', 'PromptService', 'RRuleToAPI', 'moment', + 'WorkflowJobTemplateModel', 'TemplatesStrings', function($filter, $state, $stateParams, $http, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange, GetBasePath, Rest, ParentObject, JobTemplate, $q, Empty, SchedulePost, - ProcessErrors, SchedulerInit, $location, PromptService, RRuleToAPI, moment) { + ProcessErrors, SchedulerInit, $location, PromptService, RRuleToAPI, moment, + WorkflowJobTemplate, TemplatesStrings + ) { var base = $scope.base || $location.path().replace(/^\//, '').split('/')[0], scheduler, @@ -29,6 +32,9 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', $scope.$parent.schedulerEndDt = month + '/' + day + '/' + dt.getFullYear(); }; + $scope.preventCredsWithPasswords = true; + $scope.strings = TemplatesStrings; + /* * This is a workaround for the angular-scheduler library inserting `ll` into fields after an * invalid entry and never unsetting them. Presumably null is being truncated down to 2 chars @@ -86,7 +92,7 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', // extra_data field is not manifested in the UI when scheduling a Management Job if ($state.current.name === 'jobTemplateSchedules.add'){ $scope.parseType = 'yaml'; - $scope.extraVars = '---'; + $scope.extraVars = ParentObject.extra_vars === '' ? '---' : ParentObject.extra_vars; ParseTypeChange({ scope: $scope, @@ -111,20 +117,20 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', $scope.$watchGroup(promptValuesToWatch, function() { let missingPromptValue = false; - if($scope.missingSurveyValue) { + if ($scope.missingSurveyValue) { missingPromptValue = true; - } else if(!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { + } else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { missingPromptValue = true; } $scope.promptModalMissingReqFields = missingPromptValue; }); }; - if(!launchConf.ask_variables_on_launch) { + if (!launchConf.ask_variables_on_launch) { $scope.noVars = true; } - if(!launchConf.survey_enabled && + if (!launchConf.survey_enabled && !launchConf.ask_inventory_on_launch && !launchConf.ask_credential_on_launch && !launchConf.ask_verbosity_on_launch && @@ -146,13 +152,80 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', // Promptable variables will happen in the schedule form launchConf.ignore_ask_variables = true; - if(launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory')) { $scope.promptModalMissingReqFields = true; } - if(launchConf.survey_enabled) { + if (launchConf.survey_enabled) { // go out and get the survey questions jobTemplate.getSurveyQuestions(ParentObject.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec + }); + + $scope.missingSurveyValue = processed.missingSurveyValue; + + $scope.promptData = { + launchConf: responses[1].data, + launchOptions: responses[0].data, + surveyQuestions: processed.surveyQuestions, + template: ParentObject.id, + prompts: PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data + }), + }; + + $scope.$watch('promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.promptData.surveyQuestions, (question) => { + if (question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); + + watchForPromptChanges(); + }); + } else { + $scope.promptData = { + launchConf: responses[1].data, + launchOptions: responses[0].data, + template: ParentObject.id, + prompts: PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data + }), + }; + + watchForPromptChanges(); + } + } + }); + } else if ($state.current.name === 'workflowJobTemplateSchedules.add'){ + let workflowJobTemplate = new WorkflowJobTemplate(); + + $q.all([workflowJobTemplate.optionsLaunch(ParentObject.id), workflowJobTemplate.getLaunch(ParentObject.id)]) + .then((responses) => { + let launchConf = responses[1].data; + + let watchForPromptChanges = () => { + $scope.$watch('missingSurveyValue', function() { + $scope.promptModalMissingReqFields = $scope.missingSurveyValue ? true : false; + }); + }; + + if(!launchConf.survey_enabled) { + $scope.showPromptButton = false; + } else { + $scope.showPromptButton = true; + + if(launchConf.survey_enabled) { + // go out and get the survey questions + workflowJobTemplate.getSurveyQuestions(ParentObject.id) .then((surveyQuestionRes) => { let processed = PromptService.processSurveyQuestions({ @@ -201,34 +274,18 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', } }); } - else if ($state.current.name === 'workflowJobTemplateSchedules.add'){ - $scope.parseType = 'yaml'; - // grab any existing extra_vars from parent workflow_job_template - let defaultUrl = GetBasePath('workflow_job_templates') + $stateParams.id + '/'; - Rest.setUrl(defaultUrl); - Rest.get().then(function(res){ - var data = res.data.extra_vars; - $scope.extraVars = data === '' ? '---' : data; - ParseTypeChange({ - scope: $scope, - variable: 'extraVars', - parse_variable: 'parseType', - field_id: 'SchedulerForm-extraVars' - }); - }); - } - else if ($state.current.name === 'projectSchedules.add'){ - $scope.noVars = true; - } - else if ($state.current.name === 'inventories.edit.inventory_sources.edit.schedules.add'){ + + if ($state.current.name === 'workflowJobTemplateSchedules.add' || + $state.current.name === 'projectSchedules.add' || + $state.current.name === 'inventories.edit.inventory_sources.edit.schedules.add' + ){ $scope.noVars = true; } job_type = $scope.parentObject.job_type; if (!Empty($stateParams.id) && base !== 'system_job_templates' && base !== 'inventories' && !schedule_url) { schedule_url = GetBasePath(base) + $stateParams.id + '/schedules/'; - } - else if(base === "inventories"){ + } else if (base === "inventories"){ if (!schedule_url){ Rest.setUrl(GetBasePath('groups') + $stateParams.id + '/'); Rest.get() @@ -242,43 +299,9 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', }); }); } - } - else if (base === 'system_job_templates') { + } else if (base === 'system_job_templates') { schedule_url = GetBasePath(base) + $stateParams.id + '/schedules/'; - if(job_type === "cleanup_facts"){ - $scope.isFactCleanup = true; - $scope.keep_unit_choices = [{ - "label" : "Days", - "value" : "d" - }, - { - "label": "Weeks", - "value" : "w" - }, - { - "label" : "Years", - "value" : "y" - }]; - $scope.granularity_keep_unit_choices = [{ - "label" : "Days", - "value" : "d" - }, - { - "label": "Weeks", - "value" : "w" - }, - { - "label" : "Years", - "value" : "y" - }]; - $scope.prompt_for_days_facts_form.keep_amount.$setViewValue(30); - $scope.prompt_for_days_facts_form.granularity_keep_amount.$setViewValue(1); - $scope.keep_unit = $scope.keep_unit_choices[0]; - $scope.granularity_keep_unit = $scope.granularity_keep_unit_choices[1]; - } - else { - $scope.cleanupJob = true; - } + $scope.cleanupJob = true; } Wait('start'); @@ -295,15 +318,14 @@ export default ['$filter', '$state', '$stateParams', '$http', 'Wait', }); $scope.scheduleTimeChange(); }); - if($scope.schedulerUTCTime) { + if ($scope.schedulerUTCTime) { // The UTC time is already set processSchedulerEndDt(); - } - else { + } else { // We need to wait for it to be set by angular-scheduler because the following function depends // on it var schedulerUTCTimeWatcher = $scope.$watch('schedulerUTCTime', function(newVal) { - if(newVal) { + if (newVal) { // Remove the watcher schedulerUTCTimeWatcher(); processSchedulerEndDt(); diff --git a/awx/ui/client/src/scheduler/schedulerEdit.controller.js b/awx/ui/client/src/scheduler/schedulerEdit.controller.js index 9e6c54fb7c..339b46740c 100644 --- a/awx/ui/client/src/scheduler/schedulerEdit.controller.js +++ b/awx/ui/client/src/scheduler/schedulerEdit.controller.js @@ -1,12 +1,17 @@ export default ['$filter', '$state', '$stateParams', 'Wait', '$scope', 'moment', '$rootScope', '$http', 'CreateSelect2', 'ParseTypeChange', 'ParentObject', 'ProcessErrors', 'Rest', 'GetBasePath', 'SchedulerInit', 'SchedulePost', 'JobTemplateModel', '$q', 'Empty', 'PromptService', 'RRuleToAPI', +'WorkflowJobTemplateModel', 'TemplatesStrings', function($filter, $state, $stateParams, Wait, $scope, moment, $rootScope, $http, CreateSelect2, ParseTypeChange, ParentObject, ProcessErrors, Rest, - GetBasePath, SchedulerInit, SchedulePost, JobTemplate, $q, Empty, PromptService, RRuleToAPI) { + GetBasePath, SchedulerInit, SchedulePost, JobTemplate, $q, Empty, PromptService, RRuleToAPI, + WorkflowJobTemplate, TemplatesStrings +) { let schedule, scheduler, scheduleCredentials = []; + $scope.preventCredsWithPasswords = true; + // initial end @ midnight values $scope.schedulerEndHour = "00"; $scope.schedulerEndMinute = "00"; @@ -16,6 +21,8 @@ function($filter, $state, $stateParams, Wait, $scope, moment, $scope.hideForm = true; $scope.parseType = 'yaml'; + $scope.strings = TemplatesStrings; + $scope.processSchedulerEndDt = function(){ // set the schedulerEndDt to be equal to schedulerStartDt + 1 day @ midnight var dt = new Date($scope.schedulerUTCTime); @@ -124,10 +131,8 @@ function($filter, $state, $stateParams, Wait, $scope, moment, $scope.extraVars = (data.extra_data === '' || _.isEmpty(data.extra_data)) ? '---' : '---\n' + jsyaml.safeDump(data.extra_data); - if(schedule.extra_data.hasOwnProperty('granularity')){ - $scope.isFactCleanup = true; - } - if (schedule.extra_data.hasOwnProperty('days')){ + if (_.has(schedule, 'summary_fields.unified_job_template.unified_job_type') && + schedule.summary_fields.unified_job_template.unified_job_type === 'system_job'){ $scope.cleanupJob = true; } @@ -139,8 +144,13 @@ function($filter, $state, $stateParams, Wait, $scope, moment, $http.get('/api/v2/schedules/zoneinfo/').then(({data}) => { scheduler.scope.timeZones = data; scheduler.scope.schedulerTimeZone = _.find(data, function(x) { - let tz = $scope.schedule_obj.rrule.match(/TZID=\s*(.*?)\s*:/)[1]; - return x.name === tz; + let tz = $scope.schedule_obj.rrule.match(/TZID=\s*(.*?)\s*:/); + if (_.has(tz, '1')) { + return x.name === tz[1]; + } else { + return false; + } + }); }); scheduler.inject('form-container', false); @@ -193,57 +203,8 @@ function($filter, $state, $stateParams, Wait, $scope, moment, scheduler.setRRule(schedule.rrule); scheduler.setName(schedule.name); - if($scope.isFactCleanup || $scope.cleanupJob){ - var a,b, prompt_for_days, - keep_unit, - granularity, - granularity_keep_unit; - - if($scope.cleanupJob){ - $scope.schedulerPurgeDays = Number(schedule.extra_data.days); - } - else if($scope.isFactCleanup){ - $scope.keep_unit_choices = [{ - "label" : "Days", - "value" : "d" - }, - { - "label": "Weeks", - "value" : "w" - }, - { - "label" : "Years", - "value" : "y" - }]; - $scope.granularity_keep_unit_choices = [{ - "label" : "Days", - "value" : "d" - }, - { - "label": "Weeks", - "value" : "w" - }, - { - "label" : "Years", - "value" : "y" - }]; - // the API returns something like 20w or 1y - a = schedule.extra_data.older_than; // "20y" - b = schedule.extra_data.granularity; // "1w" - prompt_for_days = Number(_.initial(a,1).join('')); // 20 - keep_unit = _.last(a); // "y" - granularity = Number(_.initial(b,1).join('')); // 1 - granularity_keep_unit = _.last(b); // "w" - - $scope.keep_amount = prompt_for_days; - $scope.granularity_keep_amount = granularity; - $scope.keep_unit = _.find($scope.keep_unit_choices, function(i){ - return i.value === keep_unit; - }); - $scope.granularity_keep_unit =_.find($scope.granularity_keep_unit_choices, function(i){ - return i.value === granularity_keep_unit; - }); - } + if ($scope.cleanupJob){ + $scope.schedulerPurgeDays = Number(schedule.extra_data.days); } if ($state.current.name === 'jobTemplateSchedules.edit'){ @@ -269,9 +230,9 @@ function($filter, $state, $stateParams, Wait, $scope, moment, $scope.$watchGroup(promptValuesToWatch, function() { let missingPromptValue = false; - if($scope.missingSurveyValue) { + if ($scope.missingSurveyValue) { missingPromptValue = true; - } else if(!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { + } else if (!$scope.promptData.prompts.inventory.value || !$scope.promptData.prompts.inventory.value.id) { missingPromptValue = true; } $scope.promptModalMissingReqFields = missingPromptValue; @@ -289,8 +250,8 @@ function($filter, $state, $stateParams, Wait, $scope, moment, const credentialHasScheduleOverride = (templateDefaultCred) => { let credentialHasOverride = false; scheduleCredentials.forEach((scheduleCred) => { - if(templateDefaultCred.credential_type === scheduleCred.credential_type) { - if( + if (templateDefaultCred.credential_type === scheduleCred.credential_type) { + if ( (!templateDefaultCred.vault_id && !scheduleCred.inputs.vault_id) || (templateDefaultCred.vault_id && scheduleCred.inputs.vault_id && templateDefaultCred.vault_id === scheduleCred.inputs.vault_id) ) { @@ -302,9 +263,9 @@ function($filter, $state, $stateParams, Wait, $scope, moment, return credentialHasOverride; }; - if(_.has(launchConf, 'defaults.credentials')) { + if (_.has(launchConf, 'defaults.credentials')) { launchConf.defaults.credentials.forEach((defaultCred) => { - if(!credentialHasScheduleOverride(defaultCred)) { + if (!credentialHasScheduleOverride(defaultCred)) { defaultCredsWithoutOverrides.push(defaultCred); } }); @@ -312,11 +273,11 @@ function($filter, $state, $stateParams, Wait, $scope, moment, prompts.credentials.value = defaultCredsWithoutOverrides.concat(scheduleCredentials); - if(!launchConf.ask_variables_on_launch) { + if (!launchConf.ask_variables_on_launch) { $scope.noVars = true; } - if(!launchConf.survey_enabled && + if (!launchConf.survey_enabled && !launchConf.ask_inventory_on_launch && !launchConf.ask_credential_on_launch && !launchConf.ask_verbosity_on_launch && @@ -338,11 +299,11 @@ function($filter, $state, $stateParams, Wait, $scope, moment, // Promptable variables will happen in the schedule form launchConf.ignore_ask_variables = true; - if(launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has(data, 'summary_fields.inventory')) { + if (launchConf.ask_inventory_on_launch && !_.has(launchConf, 'defaults.inventory') && !_.has(data, 'summary_fields.inventory')) { $scope.promptModalMissingReqFields = true; } - if(responses[1].data.survey_enabled) { + if (responses[1].data.survey_enabled) { // go out and get the survey questions jobTemplate.getSurveyQuestions(ParentObject.id) .then((surveyQuestionRes) => { @@ -372,6 +333,74 @@ function($filter, $state, $stateParams, Wait, $scope, moment, template: ParentObject.id }; + $scope.$watch('promptData.surveyQuestions', () => { + let missingSurveyValue = false; + _.each($scope.promptData.surveyQuestions, (question) => { + if (question.required && (Empty(question.model) || question.model === [])) { + missingSurveyValue = true; + } + }); + $scope.missingSurveyValue = missingSurveyValue; + }, true); + + watchForPromptChanges(); + }); + } else { + $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + template: ParentObject.id + }; + watchForPromptChanges(); + } + } + }); + } else if ($state.current.name === 'workflowJobTemplateSchedules.edit') { + let workflowJobTemplate = new WorkflowJobTemplate(); + + $q.all([workflowJobTemplate.optionsLaunch(ParentObject.id), workflowJobTemplate.getLaunch(ParentObject.id)]) + .then((responses) => { + let launchOptions = responses[0].data, + launchConf = responses[1].data; + + let watchForPromptChanges = () => { + $scope.$watch('missingSurveyValue', function() { + $scope.promptModalMissingReqFields = $scope.missingSurveyValue ? true : false; + }); + }; + + let prompts = PromptService.processPromptValues({ + launchConf: responses[1].data, + launchOptions: responses[0].data, + currentValues: data + }); + + if(!launchConf.survey_enabled) { + $scope.showPromptButton = false; + } else { + $scope.showPromptButton = true; + + if(responses[1].data.survey_enabled) { + // go out and get the survey questions + workflowJobTemplate.getSurveyQuestions(ParentObject.id) + .then((surveyQuestionRes) => { + + let processed = PromptService.processSurveyQuestions({ + surveyQuestions: surveyQuestionRes.data.spec, + extra_data: _.cloneDeep(data.extra_data) + }); + + $scope.missingSurveyValue = processed.missingSurveyValue; + + $scope.promptData = { + launchConf: launchConf, + launchOptions: launchOptions, + prompts: prompts, + surveyQuestions: surveyQuestionRes.data.spec, + template: ParentObject.id + }; + $scope.$watch('promptData.surveyQuestions', () => { let missingSurveyValue = false; _.each($scope.promptData.surveyQuestions, (question) => { @@ -400,13 +429,12 @@ function($filter, $state, $stateParams, Wait, $scope, moment, // extra_data field is not manifested in the UI when scheduling a Management Job if ($state.current.name !== 'managementJobsList.schedule.add' && $state.current.name !== 'managementJobsList.schedule.edit'){ - if ($state.current.name === 'projectSchedules.edit'){ + if ($state.current.name === 'projectSchedules.edit' || + $state.current.name === 'inventories.edit.inventory_sources.edit.schedules.edit' || + $state.current.name === 'workflowJobTemplateSchedules.add' + ){ $scope.noVars = true; - } - else if ($state.current.name === 'inventories.edit.inventory_sources.edit.schedules.edit'){ - $scope.noVars = true; - } - else { + } else { ParseTypeChange({ scope: $scope, variable: 'extraVars', diff --git a/awx/ui/client/src/scheduler/schedulerForm.partial.html b/awx/ui/client/src/scheduler/schedulerForm.partial.html index bea1d9eade..9313db7f71 100644 --- a/awx/ui/client/src/scheduler/schedulerForm.partial.html +++ b/awx/ui/client/src/scheduler/schedulerForm.partial.html @@ -686,5 +686,5 @@ ng-disabled="!schedulerIsValid || promptModalMissingReqFields"> Save
- + diff --git a/awx/ui/client/src/shared/Utilities.js b/awx/ui/client/src/shared/Utilities.js index 23d593ccd2..301a7eae9d 100644 --- a/awx/ui/client/src/shared/Utilities.js +++ b/awx/ui/client/src/shared/Utilities.js @@ -222,14 +222,14 @@ angular.module('Utilities', ['RestServices', 'Utilities']) } else if (typeof data === 'object' && data !== null) { if (Object.keys(data).length > 0) { keys = Object.keys(data); - if (Array.isArray(data[keys[0]])) { - msg = data[keys[0]][0]; - } else { - msg = ""; - _.forOwn(data, function(value, key) { + msg = ""; + _.forOwn(data, function(value, key) { + if (Array.isArray(data[key])) { + msg += `${key}: ${data[key][0]}`; + } else { msg += `${key} : ${value} `; - }); - } + } + }); Alert(defaultMsg.hdr, msg); } else { Alert(defaultMsg.hdr, defaultMsg.msg); diff --git a/awx/ui/client/src/shared/form-generator.js b/awx/ui/client/src/shared/form-generator.js index 892b156d5c..574e8e8342 100644 --- a/awx/ui/client/src/shared/form-generator.js +++ b/awx/ui/client/src/shared/form-generator.js @@ -142,10 +142,10 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat .factory('GenerateForm', ['$rootScope', '$compile', 'generateList', 'Attr', 'Icon', 'Column', 'NavigationLink', 'HelpCollapse', 'Empty', 'SelectIcon', - 'ActionButton', '$log', 'i18n', + 'ActionButton', 'MessageBar', '$log', 'i18n', function ($rootScope, $compile, GenerateList, Attr, Icon, Column, NavigationLink, HelpCollapse, - Empty, SelectIcon, ActionButton, $log, i18n) { + Empty, SelectIcon, ActionButton, MessageBar, $log, i18n) { return { setForm: function (form) { this.form = form; }, @@ -177,6 +177,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat else { return `
+ ${MessageBar(this.form)}
${html}
@@ -544,6 +545,8 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat html += "' "; html += (field.ngDisabled) ? `ng-disabled="${field.ngDisabled}" ` : ""; html += " class='ScheduleToggle-switch' ng-click='" + field.ngClick + "' translate>" + i18n._("OFF") + "
"; + } else if (field.type === 'html') { + html += field.html; } return html; }, @@ -598,6 +601,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat label = (includeLabel !== undefined && includeLabel === false) ? false : true; if (label) { + html += ""; html += "