From a2fd78add4abc32149023c808d9510f662fe1d51 Mon Sep 17 00:00:00 2001 From: Aaron Tan Date: Wed, 18 Oct 2017 11:50:29 -0400 Subject: [PATCH 01/82] Implement item copy feature See acceptance doc for implement details. Signed-off-by: Aaron Tan --- awx/api/generics.py | 161 ++++++++++++- awx/api/serializers.py | 22 ++ awx/api/urls/credential.py | 2 + awx/api/urls/inventory.py | 2 + awx/api/urls/inventory_script.py | 2 + awx/api/urls/job_template.py | 2 + awx/api/urls/notification_template.py | 2 + awx/api/urls/project.py | 4 +- awx/api/views.py | 65 ++++-- awx/main/models/inventory.py | 9 + awx/main/models/jobs.py | 4 + awx/main/models/projects.py | 2 + awx/main/models/workflow.py | 15 +- awx/main/tasks.py | 58 +++++ awx/main/tests/functional/conftest.py | 10 +- .../tests/functional/models/test_workflow.py | 19 -- awx/main/tests/functional/test_copy.py | 214 ++++++++++++++++++ docs/resource_copy.md | 166 ++++++++++++++ tools/scripts/list_fields.py | 46 ++++ 19 files changed, 763 insertions(+), 42 deletions(-) create mode 100644 awx/main/tests/functional/test_copy.py create mode 100644 docs/resource_copy.md create mode 100755 tools/scripts/list_fields.py diff --git a/awx/api/generics.py b/awx/api/generics.py index 73c8ddd1db..bee184c838 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -5,6 +5,7 @@ import inspect import logging import time +import six # Django from django.conf import settings @@ -26,6 +27,10 @@ from rest_framework import generics from rest_framework.response import Response from rest_framework import status from rest_framework import views +from rest_framework.permissions import AllowAny + +# cryptography +from cryptography.fernet import InvalidToken # AWX from awx.api.filters import FieldLookupBackend @@ -33,9 +38,9 @@ from awx.main.models import * # noqa from awx.main.access import access_registry from awx.main.utils import * # noqa from awx.main.utils.db import get_all_field_names -from awx.api.serializers import ResourceAccessListElementSerializer +from awx.api.serializers import ResourceAccessListElementSerializer, CopySerializer from awx.api.versioning import URLPathVersioning, get_request_version -from awx.api.metadata import SublistAttachDetatchMetadata +from awx.api.metadata import SublistAttachDetatchMetadata, Metadata __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView', 'ListCreateAPIView', 'SubListAPIView', 'SubListCreateAPIView', @@ -47,7 +52,8 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView', 'ResourceAccessList', 'ParentMixin', 'DeleteLastUnattachLabelMixin', - 'SubListAttachDetachAPIView',] + 'SubListAttachDetachAPIView', + 'CopyAPIView'] logger = logging.getLogger('awx.api.generics') analytics_logger = logging.getLogger('awx.analytics.performance') @@ -747,3 +753,152 @@ class ResourceAccessList(ParentMixin, ListAPIView): for r in roles: ancestors.update(set(r.ancestors.all())) return User.objects.filter(roles__in=list(ancestors)).distinct() + + +def trigger_delayed_deep_copy(*args, **kwargs): + from awx.main.tasks import deep_copy_model_obj + connection.on_commit(lambda: deep_copy_model_obj.delay(*args, **kwargs)) + + +class CopyAPIView(GenericAPIView): + + serializer_class = CopySerializer + permission_classes = (AllowAny,) + copy_return_serializer_class = None + new_in_330 = True + new_in_api_v2 = True + + def _get_copy_return_serializer(self, *args, **kwargs): + if not self.copy_return_serializer_class: + return self.get_serializer(*args, **kwargs) + serializer_class_store = self.serializer_class + self.serializer_class = self.copy_return_serializer_class + ret = self.get_serializer(*args, **kwargs) + self.serializer_class = serializer_class_store + return ret + + @staticmethod + def _decrypt_model_field_if_needed(obj, field_name, field_val): + if field_name in getattr(type(obj), 'REENCRYPTION_BLACKLIST_AT_COPY', []): + return field_val + if isinstance(field_val, dict): + for sub_field in field_val: + if isinstance(sub_field, six.string_types) \ + and isinstance(field_val[sub_field], six.string_types): + try: + field_val[sub_field] = decrypt_field(obj, field_name, sub_field) + except InvalidToken: + # Catching the corner case with v1 credential fields + field_val[sub_field] = decrypt_field(obj, sub_field) + elif isinstance(field_val, six.string_types): + field_val = decrypt_field(obj, field_name) + return field_val + + def _build_create_dict(self, obj): + ret = {} + if self.copy_return_serializer_class: + all_fields = Metadata().get_serializer_info( + self._get_copy_return_serializer(), method='POST' + ) + for field_name, field_info in all_fields.items(): + if not hasattr(obj, field_name) or field_info.get('read_only', True): + continue + ret[field_name] = CopyAPIView._decrypt_model_field_if_needed( + obj, field_name, getattr(obj, field_name) + ) + return ret + + @staticmethod + def copy_model_obj(old_parent, new_parent, model, obj, creater, copy_name='', create_kwargs=None): + fields_to_preserve = set(getattr(model, 'FIELDS_TO_PRESERVE_AT_COPY', [])) + fields_to_discard = set(getattr(model, 'FIELDS_TO_DISCARD_AT_COPY', [])) + m2m_to_preserve = {} + o2m_to_preserve = {} + create_kwargs = create_kwargs or {} + for field_name in fields_to_discard: + create_kwargs.pop(field_name, None) + for field in model._meta.get_fields(): + try: + field_val = getattr(obj, field.name) + except AttributeError: + continue + # Adjust copy blacklist fields here. + if field.name in fields_to_discard or field.name in [ + 'id', 'pk', 'polymorphic_ctype', 'unifiedjobtemplate_ptr', 'created_by', 'modified_by' + ] or field.name.endswith('_role'): + create_kwargs.pop(field.name, None) + continue + if field.one_to_many: + if field.name in fields_to_preserve: + o2m_to_preserve[field.name] = field_val + elif field.many_to_many: + if field.name in fields_to_preserve and not old_parent: + m2m_to_preserve[field.name] = field_val + elif field.many_to_one and not field_val: + create_kwargs.pop(field.name, None) + elif field.many_to_one and field_val == old_parent: + create_kwargs[field.name] = new_parent + elif field.name == 'name' and not old_parent: + create_kwargs[field.name] = copy_name or field_val + ' copy' + elif field.name in fields_to_preserve: + create_kwargs[field.name] = CopyAPIView._decrypt_model_field_if_needed( + obj, field.name, field_val + ) + new_obj = model.objects.create(**create_kwargs) + # Need to save separatedly because Djang-crum get_current_user would + # not work properly in non-request-response-cycle context. + new_obj.created_by = creater + new_obj.save() + for m2m in m2m_to_preserve: + for related_obj in m2m_to_preserve[m2m].all(): + getattr(new_obj, m2m).add(related_obj) + if not old_parent: + sub_objects = [] + for o2m in o2m_to_preserve: + for sub_obj in o2m_to_preserve[o2m].all(): + sub_model = type(sub_obj) + sub_objects.append((sub_model.__module__, sub_model.__name__, sub_obj.pk)) + return new_obj, sub_objects + ret = {obj: new_obj} + for o2m in o2m_to_preserve: + for sub_obj in o2m_to_preserve[o2m].all(): + ret.update(CopyAPIView.copy_model_obj(obj, new_obj, type(sub_obj), sub_obj, creater)) + return ret + + def get(self, request, *args, **kwargs): + obj = self.get_object() + create_kwargs = self._build_create_dict(obj) + for key in create_kwargs: + create_kwargs[key] = getattr(create_kwargs[key], 'pk', None) or create_kwargs[key] + return Response({'can_copy': request.user.can_access(self.model, 'add', create_kwargs)}) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + create_kwargs = self._build_create_dict(obj) + create_kwargs_check = {} + for key in create_kwargs: + create_kwargs_check[key] = getattr(create_kwargs[key], 'pk', None) or create_kwargs[key] + if not request.user.can_access(self.model, 'add', create_kwargs_check): + raise PermissionDenied() + serializer = self.get_serializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + new_obj, sub_objs = CopyAPIView.copy_model_obj( + None, None, self.model, obj, request.user, create_kwargs=create_kwargs, + copy_name=serializer.validated_data.get('name', '') + ) + if hasattr(new_obj, 'admin_role') and request.user not in new_obj.admin_role: + new_obj.admin_role.members.add(request.user) + if sub_objs: + permission_check_func = None + if hasattr(type(self), 'deep_copy_permission_check_func'): + permission_check_func = ( + type(self).__module__, type(self).__name__, 'deep_copy_permission_check_func' + ) + trigger_delayed_deep_copy( + self.model.__module__, self.model.__name__, + obj.pk, new_obj.pk, request.user.pk, sub_objs, + permission_check_func=permission_check_func + ) + serializer = self._get_copy_return_serializer(new_obj) + return Response(serializer.data, status=status.HTTP_201_CREATED) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3923e5c9ef..5844cbdfed 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -131,6 +131,22 @@ def reverse_gfk(content_object, request): } +class CopySerializer(serializers.Serializer): + + name = serializers.CharField() + + def validate(self, attrs): + name = attrs.get('name') + view = self.context.get('view', None) + obj = view.get_object() + if name == obj.name: + raise serializers.ValidationError(_( + 'The original object is already named {}, a copy from' + ' it cannot have the same name.'.format(name) + )) + return attrs + + class BaseSerializerMetaclass(serializers.SerializerMetaclass): ''' Custom metaclass to enable attribute inheritance from Meta objects on @@ -1022,6 +1038,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): notification_templates_error = self.reverse('api:project_notification_templates_error_list', kwargs={'pk': obj.pk}), access_list = self.reverse('api:project_access_list', kwargs={'pk': obj.pk}), object_roles = self.reverse('api:project_object_roles_list', kwargs={'pk': obj.pk}), + copy = self.reverse('api:project_copy', kwargs={'pk': obj.pk}), )) if obj.organization: res['organization'] = self.reverse('api:organization_detail', @@ -1174,6 +1191,7 @@ class InventorySerializer(BaseSerializerWithVariables): access_list = self.reverse('api:inventory_access_list', kwargs={'pk': obj.pk}), object_roles = self.reverse('api:inventory_object_roles_list', kwargs={'pk': obj.pk}), instance_groups = self.reverse('api:inventory_instance_groups_list', kwargs={'pk': obj.pk}), + copy = self.reverse('api:inventory_copy', kwargs={'pk': obj.pk}), )) if obj.insights_credential: res['insights_credential'] = self.reverse('api:credential_detail', kwargs={'pk': obj.insights_credential.pk}) @@ -1530,6 +1548,7 @@ class CustomInventoryScriptSerializer(BaseSerializer): res = super(CustomInventoryScriptSerializer, self).get_related(obj) res.update(dict( object_roles = self.reverse('api:inventory_script_object_roles_list', kwargs={'pk': obj.pk}), + copy = self.reverse('api:inventory_script_copy', kwargs={'pk': obj.pk}), )) if obj.organization: @@ -2086,6 +2105,7 @@ class CredentialSerializer(BaseSerializer): object_roles = self.reverse('api:credential_object_roles_list', kwargs={'pk': obj.pk}), owner_users = self.reverse('api:credential_owner_users_list', kwargs={'pk': obj.pk}), owner_teams = self.reverse('api:credential_owner_teams_list', kwargs={'pk': obj.pk}), + copy = self.reverse('api:credential_copy', kwargs={'pk': obj.pk}), )) # TODO: remove when API v1 is removed @@ -2563,6 +2583,7 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO labels = self.reverse('api:job_template_label_list', kwargs={'pk': obj.pk}), object_roles = self.reverse('api:job_template_object_roles_list', kwargs={'pk': obj.pk}), instance_groups = self.reverse('api:job_template_instance_groups_list', kwargs={'pk': obj.pk}), + copy = self.reverse('api:job_template_copy', kwargs={'pk': obj.pk}), )) if obj.host_config_key: res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk}) @@ -3686,6 +3707,7 @@ class NotificationTemplateSerializer(BaseSerializer): res.update(dict( test = self.reverse('api:notification_template_test', kwargs={'pk': obj.pk}), notifications = self.reverse('api:notification_template_notification_list', kwargs={'pk': obj.pk}), + copy = self.reverse('api:notification_template_copy', kwargs={'pk': obj.pk}), )) if obj.organization: res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization.pk}) diff --git a/awx/api/urls/credential.py b/awx/api/urls/credential.py index b8480ab4e8..c444da9090 100644 --- a/awx/api/urls/credential.py +++ b/awx/api/urls/credential.py @@ -11,6 +11,7 @@ from awx.api.views import ( CredentialObjectRolesList, CredentialOwnerUsersList, CredentialOwnerTeamsList, + CredentialCopy, ) @@ -22,6 +23,7 @@ urls = [ url(r'^(?P[0-9]+)/object_roles/$', CredentialObjectRolesList.as_view(), name='credential_object_roles_list'), url(r'^(?P[0-9]+)/owner_users/$', CredentialOwnerUsersList.as_view(), name='credential_owner_users_list'), url(r'^(?P[0-9]+)/owner_teams/$', CredentialOwnerTeamsList.as_view(), name='credential_owner_teams_list'), + url(r'^(?P[0-9]+)/copy/$', CredentialCopy.as_view(), name='credential_copy'), ] __all__ = ['urls'] diff --git a/awx/api/urls/inventory.py b/awx/api/urls/inventory.py index 0d8e2ca8d5..c2f67ab457 100644 --- a/awx/api/urls/inventory.py +++ b/awx/api/urls/inventory.py @@ -20,6 +20,7 @@ from awx.api.views import ( InventoryAccessList, InventoryObjectRolesList, InventoryInstanceGroupsList, + InventoryCopy, ) @@ -40,6 +41,7 @@ urls = [ url(r'^(?P[0-9]+)/access_list/$', InventoryAccessList.as_view(), name='inventory_access_list'), url(r'^(?P[0-9]+)/object_roles/$', InventoryObjectRolesList.as_view(), name='inventory_object_roles_list'), url(r'^(?P[0-9]+)/instance_groups/$', InventoryInstanceGroupsList.as_view(), name='inventory_instance_groups_list'), + url(r'^(?P[0-9]+)/copy/$', InventoryCopy.as_view(), name='inventory_copy'), ] __all__ = ['urls'] diff --git a/awx/api/urls/inventory_script.py b/awx/api/urls/inventory_script.py index 088ccf21ca..03852e78cb 100644 --- a/awx/api/urls/inventory_script.py +++ b/awx/api/urls/inventory_script.py @@ -7,6 +7,7 @@ from awx.api.views import ( InventoryScriptList, InventoryScriptDetail, InventoryScriptObjectRolesList, + InventoryScriptCopy, ) @@ -14,6 +15,7 @@ urls = [ url(r'^$', InventoryScriptList.as_view(), name='inventory_script_list'), url(r'^(?P[0-9]+)/$', InventoryScriptDetail.as_view(), name='inventory_script_detail'), url(r'^(?P[0-9]+)/object_roles/$', InventoryScriptObjectRolesList.as_view(), name='inventory_script_object_roles_list'), + url(r'^(?P[0-9]+)/copy/$', InventoryScriptCopy.as_view(), name='inventory_script_copy'), ] __all__ = ['urls'] diff --git a/awx/api/urls/job_template.py b/awx/api/urls/job_template.py index 32b11444be..b11dbf4fea 100644 --- a/awx/api/urls/job_template.py +++ b/awx/api/urls/job_template.py @@ -19,6 +19,7 @@ from awx.api.views import ( JobTemplateAccessList, JobTemplateObjectRolesList, JobTemplateLabelList, + JobTemplateCopy, ) @@ -41,6 +42,7 @@ urls = [ url(r'^(?P[0-9]+)/access_list/$', JobTemplateAccessList.as_view(), name='job_template_access_list'), url(r'^(?P[0-9]+)/object_roles/$', JobTemplateObjectRolesList.as_view(), name='job_template_object_roles_list'), url(r'^(?P[0-9]+)/labels/$', JobTemplateLabelList.as_view(), name='job_template_label_list'), + url(r'^(?P[0-9]+)/copy/$', JobTemplateCopy.as_view(), name='job_template_copy'), ] __all__ = ['urls'] diff --git a/awx/api/urls/notification_template.py b/awx/api/urls/notification_template.py index eba6be5ef3..8473878922 100644 --- a/awx/api/urls/notification_template.py +++ b/awx/api/urls/notification_template.py @@ -8,6 +8,7 @@ from awx.api.views import ( NotificationTemplateDetail, NotificationTemplateTest, NotificationTemplateNotificationList, + NotificationTemplateCopy, ) @@ -16,6 +17,7 @@ urls = [ url(r'^(?P[0-9]+)/$', NotificationTemplateDetail.as_view(), name='notification_template_detail'), url(r'^(?P[0-9]+)/test/$', NotificationTemplateTest.as_view(), name='notification_template_test'), url(r'^(?P[0-9]+)/notifications/$', NotificationTemplateNotificationList.as_view(), name='notification_template_notification_list'), + url(r'^(?P[0-9]+)/copy/$', NotificationTemplateCopy.as_view(), name='notification_template_copy'), ] __all__ = ['urls'] diff --git a/awx/api/urls/project.py b/awx/api/urls/project.py index 629ec1ce05..263014e6e2 100644 --- a/awx/api/urls/project.py +++ b/awx/api/urls/project.py @@ -19,10 +19,11 @@ from awx.api.views import ( ProjectNotificationTemplatesSuccessList, ProjectObjectRolesList, ProjectAccessList, + ProjectCopy, ) -urls = [ +urls = [ url(r'^$', ProjectList.as_view(), name='project_list'), url(r'^(?P[0-9]+)/$', ProjectDetail.as_view(), name='project_detail'), url(r'^(?P[0-9]+)/playbooks/$', ProjectPlaybooks.as_view(), name='project_playbooks'), @@ -39,6 +40,7 @@ urls = [ name='project_notification_templates_success_list'), url(r'^(?P[0-9]+)/object_roles/$', ProjectObjectRolesList.as_view(), name='project_object_roles_list'), url(r'^(?P[0-9]+)/access_list/$', ProjectAccessList.as_view(), name='project_access_list'), + url(r'^(?P[0-9]+)/copy/$', ProjectCopy.as_view(), name='project_copy'), ] __all__ = ['urls'] diff --git a/awx/api/views.py b/awx/api/views.py index a6067d3268..a8dec129ca 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -1424,6 +1424,12 @@ class ProjectObjectRolesList(SubListAPIView): return Role.objects.filter(content_type=content_type, object_id=po.pk) +class ProjectCopy(CopyAPIView): + + model = Project + copy_return_serializer_class = ProjectSerializer + + class UserList(ListCreateAPIView): model = User @@ -1805,6 +1811,12 @@ class CredentialObjectRolesList(SubListAPIView): return Role.objects.filter(content_type=content_type, object_id=po.pk) +class CredentialCopy(CopyAPIView): + + model = Credential + copy_return_serializer_class = CredentialSerializer + + class InventoryScriptList(ListCreateAPIView): model = CustomInventoryScript @@ -1842,6 +1854,12 @@ class InventoryScriptObjectRolesList(SubListAPIView): return Role.objects.filter(content_type=content_type, object_id=po.pk) +class InventoryScriptCopy(CopyAPIView): + + model = CustomInventoryScript + copy_return_serializer_class = CustomInventoryScriptSerializer + + class InventoryList(ListCreateAPIView): model = Inventory @@ -1969,6 +1987,12 @@ class InventoryJobTemplateList(SubListAPIView): return qs.filter(inventory=parent) +class InventoryCopy(CopyAPIView): + + model = Inventory + copy_return_serializer_class = InventorySerializer + + class HostRelatedSearchMixin(object): @property @@ -3337,6 +3361,12 @@ class JobTemplateObjectRolesList(SubListAPIView): return Role.objects.filter(content_type=content_type, object_id=po.pk) +class JobTemplateCopy(CopyAPIView): + + model = JobTemplate + copy_return_serializer_class = JobTemplateSerializer + + class WorkflowJobNodeList(WorkflowsEnforcementMixin, ListAPIView): model = WorkflowJobNode @@ -3500,10 +3530,10 @@ class WorkflowJobTemplateDetail(WorkflowsEnforcementMixin, RetrieveUpdateDestroy new_in_310 = True -class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, GenericAPIView): +class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, CopyAPIView): model = WorkflowJobTemplate - serializer_class = EmptySerializer + copy_return_serializer_class = WorkflowJobTemplateSerializer new_in_310 = True def get(self, request, *args, **kwargs): @@ -3520,17 +3550,20 @@ class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, GenericAPIView): data.update(messages) return Response(data) - def post(self, request, *args, **kwargs): - obj = self.get_object() - if not request.user.can_access(self.model, 'copy', obj): - raise PermissionDenied() - new_obj = obj.user_copy(request.user) - if request.user not in new_obj.admin_role: - new_obj.admin_role.members.add(request.user) - data = OrderedDict() - data.update(WorkflowJobTemplateSerializer( - new_obj, context=self.get_serializer_context()).to_representation(new_obj)) - return Response(data, status=status.HTTP_201_CREATED) + @staticmethod + def deep_copy_permission_check_func(user, new_objs): + for obj in new_objs: + for field_name in obj._get_workflow_job_field_names(): + item = getattr(obj, field_name, None) + if item is None: + continue + if field_name in ['inventory']: + if not user.can_access(item.__class__, 'use', item): + setattr(obj, field_name, None) + if field_name in ['unified_job_template']: + if not user.can_access(item.__class__, 'start', item, validate_license=False): + setattr(obj, field_name, None) + obj.save() class WorkflowJobTemplateLabelList(WorkflowsEnforcementMixin, JobTemplateLabelList): @@ -4704,6 +4737,12 @@ class NotificationTemplateNotificationList(SubListAPIView): new_in_300 = True +class NotificationTemplateCopy(CopyAPIView): + + model = NotificationTemplate + copy_return_serializer_class = NotificationTemplateSerializer + + class NotificationList(ListAPIView): model = Notification diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 5f136ee5b2..e1c10de3a7 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -48,6 +48,7 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin): an inventory source contains lists and hosts. ''' + FIELDS_TO_PRESERVE_AT_COPY = ['hosts', 'groups', 'instance_groups'] KIND_CHOICES = [ ('', _('Hosts have a direct link to this inventory.')), ('smart', _('Hosts for inventory generated using the host_filter property.')), @@ -503,6 +504,10 @@ class Host(CommonModelNameNotUnique): A managed node ''' + FIELDS_TO_PRESERVE_AT_COPY = [ + 'name', 'description', 'groups', 'inventory', 'enabled', 'instance_id', 'variables' + ] + class Meta: app_label = 'main' unique_together = (("name", "inventory"),) # FIXME: Add ('instance_id', 'inventory') after migration. @@ -690,6 +695,10 @@ class Group(CommonModelNameNotUnique): groups. ''' + FIELDS_TO_PRESERVE_AT_COPY = [ + 'name', 'description', 'inventory', 'children', 'parents', 'hosts', 'variables' + ] + class Meta: app_label = 'main' unique_together = (("name", "inventory"),) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 2bb2f53ca2..baded4fba0 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -227,6 +227,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour A job template is a reusable job definition for applying a project (with playbook) to an inventory source with a given credential. ''' + FIELDS_TO_PRESERVE_AT_COPY = [ + 'labels', 'instance_groups', 'credentials', 'survey_spec' + ] + FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential'] SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] class Meta: diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index a8578b97bd..9390f6d042 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -228,6 +228,8 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin): ''' SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] + FIELDS_TO_PRESERVE_AT_COPY = ['labels', 'instance_groups', 'credentials'] + FIELDS_TO_DISCARD_AT_COPY = ['local_path'] class Meta: app_label = 'main' diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 78978f998c..ade3544dc2 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -110,6 +110,13 @@ class WorkflowNodeBase(CreatedModifiedModel, LaunchTimeConfig): class WorkflowJobTemplateNode(WorkflowNodeBase): + FIELDS_TO_PRESERVE_AT_COPY = [ + 'unified_job_template', 'workflow_job_template', 'success_nodes', 'failure_nodes', + 'always_nodes', 'credentials', 'inventory', 'extra_data', 'survey_passwords', + 'char_prompts' + ] + REENCRYPTION_BLACKLIST_AT_COPY = ['extra_data', 'survey_passwords'] + workflow_job_template = models.ForeignKey( 'WorkflowJobTemplate', related_name='workflow_job_template_nodes', @@ -283,6 +290,9 @@ class WorkflowJobOptions(BaseModel): class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTemplateMixin, ResourceMixin): SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] + FIELDS_TO_PRESERVE_AT_COPY = [ + 'labels', 'instance_groups', 'workflow_job_template_nodes', 'credentials', 'survey_spec' + ] class Meta: app_label = 'main' @@ -393,11 +403,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl node_list.append(node.pk) return node_list - def user_copy(self, user): - new_wfjt = self.copy_unified_jt() - new_wfjt.copy_nodes_from_original(original=self, user=user) - return new_wfjt - class WorkflowJob(UnifiedJob, WorkflowJobOptions, SurveyJobMixin, JobNotificationMixin): class Meta: diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 6375a532dd..afaae03cb9 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -7,6 +7,7 @@ from collections import OrderedDict import ConfigParser import cStringIO import functools +import importlib import json import logging import os @@ -33,6 +34,7 @@ from celery.signals import celeryd_init, worker_process_init, worker_shutdown # Django from django.conf import settings from django.db import transaction, DatabaseError, IntegrityError +from django.db.models.fields.related import ForeignKey from django.utils.timezone import now, timedelta from django.utils.encoding import smart_str from django.core.mail import send_mail @@ -2279,6 +2281,62 @@ class RunSystemJob(BaseTask): return settings.BASE_DIR +def _reconstruct_relationships(copy_mapping): + for old_obj, new_obj in copy_mapping.items(): + model = type(old_obj) + for field_name in getattr(model, 'FIELDS_TO_PRESERVE_AT_COPY', []): + field = model._meta.get_field(field_name) + if isinstance(field, ForeignKey): + if getattr(new_obj, field_name, None): + continue + related_obj = getattr(old_obj, field_name) + related_obj = copy_mapping.get(related_obj, related_obj) + setattr(new_obj, field_name, related_obj) + elif field.many_to_many: + for related_obj in getattr(old_obj, field_name).all(): + getattr(new_obj, field_name).add(copy_mapping.get(related_obj, related_obj)) + new_obj.save() + + +@shared_task(bind=True, queue='tower', base=LogErrorsTask) +def deep_copy_model_obj( + self, model_module, model_name, obj_pk, new_obj_pk, + user_pk, sub_obj_list, permission_check_func=None +): + logger.info('Deep copy {} from {} to {}.'.format(model_name, obj_pk, new_obj_pk)) + from awx.api.generics import CopyAPIView + model = getattr(importlib.import_module(model_module), model_name, None) + if model is None: + return + try: + obj = model.objects.get(pk=obj_pk) + new_obj = model.objects.get(pk=new_obj_pk) + creater = User.objects.get(pk=user_pk) + except ObjectDoesNotExist: + logger.warning("Object or user no longer exists.") + return + with transaction.atomic(): + copy_mapping = {} + for sub_obj_setup in sub_obj_list: + sub_model = getattr(importlib.import_module(sub_obj_setup[0]), + sub_obj_setup[1], None) + if sub_model is None: + continue + try: + sub_obj = sub_model.objects.get(pk=sub_obj_setup[2]) + except ObjectDoesNotExist: + continue + copy_mapping.update(CopyAPIView.copy_model_obj( + obj, new_obj, sub_model, sub_obj, creater + )) + _reconstruct_relationships(copy_mapping) + if permission_check_func: + permission_check_func = getattr(getattr( + importlib.import_module(permission_check_func[0]), permission_check_func[1] + ), permission_check_func[2]) + permission_check_func(creater, copy_mapping.values()) + + celery_app.register_task(RunJob()) celery_app.register_task(RunProjectUpdate()) celery_app.register_task(RunInventoryUpdate()) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index e0d793d190..4eb4bcca2f 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -33,7 +33,8 @@ from awx.main.models.inventory import ( Group, Inventory, InventoryUpdate, - InventorySource + InventorySource, + CustomInventoryScript ) from awx.main.models.organization import ( Organization, @@ -489,6 +490,13 @@ def inventory_update(inventory_source): return InventoryUpdate.objects.create(inventory_source=inventory_source) +@pytest.fixture +def inventory_script(organization): + return CustomInventoryScript.objects.create(name='test inv script', + organization=organization, + script='#!/usr/bin/python') + + @pytest.fixture def host(group, inventory): return group.hosts.create(name='single-host', inventory=inventory) diff --git a/awx/main/tests/functional/models/test_workflow.py b/awx/main/tests/functional/models/test_workflow.py index af6724d8b7..61882f2097 100644 --- a/awx/main/tests/functional/models/test_workflow.py +++ b/awx/main/tests/functional/models/test_workflow.py @@ -191,25 +191,6 @@ class TestWorkflowJobTemplate: assert (test_view.is_valid_relation(nodes[2], node_assoc_1) == {'Error': 'Cannot associate failure_nodes when always_nodes have been associated.'}) - def test_wfjt_copy(self, wfjt, job_template, inventory, admin_user): - old_nodes = wfjt.workflow_job_template_nodes.all() - node1 = old_nodes[1] - node1.unified_job_template = job_template - node1.save() - node2 = old_nodes[2] - node2.inventory = inventory - node2.save() - new_wfjt = wfjt.user_copy(admin_user) - for fd in ['description', 'survey_spec', 'survey_enabled', 'extra_vars']: - assert getattr(wfjt, fd) == getattr(new_wfjt, fd) - assert new_wfjt.organization == wfjt.organization - assert len(new_wfjt.workflow_job_template_nodes.all()) == 3 - nodes = new_wfjt.workflow_job_template_nodes.all() - assert nodes[0].success_nodes.all()[0] == nodes[1] - assert nodes[1].failure_nodes.all()[0] == nodes[2] - assert nodes[1].unified_job_template == job_template - assert nodes[2].inventory == inventory - def test_wfjt_unique_together_with_org(self, organization): wfjt1 = WorkflowJobTemplate(name='foo', organization=organization) wfjt1.save() diff --git a/awx/main/tests/functional/test_copy.py b/awx/main/tests/functional/test_copy.py new file mode 100644 index 0000000000..99e123a8fa --- /dev/null +++ b/awx/main/tests/functional/test_copy.py @@ -0,0 +1,214 @@ +import pytest +import mock + +from awx.api.versioning import reverse +from awx.main.utils import decrypt_field +from awx.main.models.workflow import WorkflowJobTemplateNode +from awx.main.models.jobs import JobTemplate +from awx.main.tasks import deep_copy_model_obj + + +@pytest.mark.django_db +def test_job_template_copy(post, get, project, inventory, machine_credential, vault_credential, + credential, alice, job_template_with_survey_passwords, admin): + job_template_with_survey_passwords.project = project + job_template_with_survey_passwords.inventory = inventory + job_template_with_survey_passwords.save() + job_template_with_survey_passwords.credentials.add(credential) + job_template_with_survey_passwords.credentials.add(machine_credential) + job_template_with_survey_passwords.credentials.add(vault_credential) + job_template_with_survey_passwords.admin_role.members.add(alice) + assert get( + reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}), + alice, expect=200 + ).data['can_copy'] is False + assert get( + reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}), + admin, expect=200 + ).data['can_copy'] is True + jt_copy_pk = post( + reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}), + {'name': 'new jt name'}, admin, expect=201 + ).data['id'] + jt_copy = type(job_template_with_survey_passwords).objects.get(pk=jt_copy_pk) + assert jt_copy.created_by == admin + assert jt_copy.name == 'new jt name' + assert jt_copy.project == project + assert jt_copy.inventory == inventory + assert jt_copy.playbook == job_template_with_survey_passwords.playbook + assert jt_copy.credentials.count() == 3 + assert credential in jt_copy.credentials.all() + assert vault_credential in jt_copy.credentials.all() + assert machine_credential in jt_copy.credentials.all() + assert job_template_with_survey_passwords.survey_spec == jt_copy.survey_spec + + +@pytest.mark.django_db +def test_project_copy(post, get, project, organization, scm_credential, alice): + project.credential = scm_credential + project.save() + project.admin_role.members.add(alice) + assert get( + reverse('api:project_copy', kwargs={'pk': project.pk}), alice, expect=200 + ).data['can_copy'] is False + project.organization.admin_role.members.add(alice) + assert get( + reverse('api:project_copy', kwargs={'pk': project.pk}), alice, expect=200 + ).data['can_copy'] is True + project_copy_pk = post( + reverse('api:project_copy', kwargs={'pk': project.pk}), + {'name': 'copied project'}, alice, expect=201 + ).data['id'] + project_copy = type(project).objects.get(pk=project_copy_pk) + assert project_copy.created_by == alice + assert project_copy.name == 'copied project' + assert project_copy.organization == organization + assert project_copy.credential == scm_credential + + +@pytest.mark.django_db +def test_inventory_copy(inventory, group_factory, post, get, alice, organization): + group_1_1 = group_factory('g_1_1') + group_2_1 = group_factory('g_2_1') + group_2_2 = group_factory('g_2_2') + group_2_1.parents.add(group_1_1) + group_2_2.parents.add(group_1_1) + group_2_2.parents.add(group_2_1) + host = group_1_1.hosts.create(name='host', inventory=inventory) + group_2_1.hosts.add(host) + inventory.admin_role.members.add(alice) + assert get( + reverse('api:inventory_copy', kwargs={'pk': inventory.pk}), alice, expect=200 + ).data['can_copy'] is False + inventory.organization.admin_role.members.add(alice) + assert get( + reverse('api:inventory_copy', kwargs={'pk': inventory.pk}), alice, expect=200 + ).data['can_copy'] is True + with mock.patch('awx.api.generics.trigger_delayed_deep_copy') as deep_copy_mock: + inv_copy_pk = post( + reverse('api:inventory_copy', kwargs={'pk': inventory.pk}), + {'name': 'new inv name'}, alice, expect=201 + ).data['id'] + inventory_copy = type(inventory).objects.get(pk=inv_copy_pk) + args, kwargs = deep_copy_mock.call_args + deep_copy_model_obj(*args, **kwargs) + group_1_1_copy = inventory_copy.groups.get(name='g_1_1') + group_2_1_copy = inventory_copy.groups.get(name='g_2_1') + group_2_2_copy = inventory_copy.groups.get(name='g_2_2') + host_copy = inventory_copy.hosts.get(name='host') + assert inventory_copy.organization == organization + assert inventory_copy.created_by == alice + assert inventory_copy.name == 'new inv name' + assert set(group_1_1_copy.parents.all()) == set() + assert set(group_2_1_copy.parents.all()) == set([group_1_1_copy]) + assert set(group_2_2_copy.parents.all()) == set([group_1_1_copy, group_2_1_copy]) + assert set(group_1_1_copy.hosts.all()) == set([host_copy]) + assert set(group_2_1_copy.hosts.all()) == set([host_copy]) + assert set(group_2_2_copy.hosts.all()) == set() + + +@pytest.mark.django_db +def test_workflow_job_template_copy(workflow_job_template, post, get, admin, organization): + workflow_job_template.organization = organization + workflow_job_template.save() + jts = [JobTemplate.objects.create(name='test-jt-{}'.format(i)) for i in range(0, 5)] + nodes = [ + WorkflowJobTemplateNode.objects.create( + workflow_job_template=workflow_job_template, unified_job_template=jts[i] + ) for i in range(0, 5) + ] + nodes[0].success_nodes.add(nodes[1]) + nodes[1].success_nodes.add(nodes[2]) + nodes[0].failure_nodes.add(nodes[3]) + nodes[3].failure_nodes.add(nodes[4]) + with mock.patch('awx.api.generics.trigger_delayed_deep_copy') as deep_copy_mock: + wfjt_copy_id = post( + reverse('api:workflow_job_template_copy', kwargs={'pk': workflow_job_template.pk}), + {'name': 'new wfjt name'}, admin, expect=201 + ).data['id'] + wfjt_copy = type(workflow_job_template).objects.get(pk=wfjt_copy_id) + args, kwargs = deep_copy_mock.call_args + deep_copy_model_obj(*args, **kwargs) + assert wfjt_copy.organization == organization + assert wfjt_copy.created_by == admin + assert wfjt_copy.name == 'new wfjt name' + copied_node_list = [x for x in wfjt_copy.workflow_job_template_nodes.all()] + copied_node_list.sort(key=lambda x: int(x.unified_job_template.name[-1])) + for node, success_count, failure_count, always_count in zip( + copied_node_list, + [1, 1, 0, 0, 0], + [1, 0, 0, 1, 0], + [0, 0, 0, 0, 0] + ): + assert node.success_nodes.count() == success_count + assert node.failure_nodes.count() == failure_count + assert node.always_nodes.count() == always_count + assert copied_node_list[1] in copied_node_list[0].success_nodes.all() + assert copied_node_list[2] in copied_node_list[1].success_nodes.all() + assert copied_node_list[3] in copied_node_list[0].failure_nodes.all() + assert copied_node_list[4] in copied_node_list[3].failure_nodes.all() + + +@pytest.mark.django_db +def test_credential_copy(post, get, machine_credential, credentialtype_ssh, admin): + assert get( + reverse('api:credential_copy', kwargs={'pk': machine_credential.pk}), admin, expect=200 + ).data['can_copy'] is True + credential_copy_pk = post( + reverse('api:credential_copy', kwargs={'pk': machine_credential.pk}), + {'name': 'copied credential'}, admin, expect=201 + ).data['id'] + credential_copy = type(machine_credential).objects.get(pk=credential_copy_pk) + assert credential_copy.created_by == admin + assert credential_copy.name == 'copied credential' + assert credential_copy.credential_type == credentialtype_ssh + assert credential_copy.inputs['username'] == machine_credential.inputs['username'] + assert (decrypt_field(credential_copy, 'password') == + decrypt_field(machine_credential, 'password')) + + +@pytest.mark.django_db +def test_notification_template_copy(post, get, notification_template_with_encrypt, + organization, alice): + #notification_template_with_encrypt.admin_role.members.add(alice) + assert get( + reverse( + 'api:notification_template_copy', kwargs={'pk': notification_template_with_encrypt.pk} + ), alice, expect=200 + ).data['can_copy'] is False + notification_template_with_encrypt.organization.admin_role.members.add(alice) + assert get( + reverse( + 'api:notification_template_copy', kwargs={'pk': notification_template_with_encrypt.pk} + ), alice, expect=200 + ).data['can_copy'] is True + nt_copy_pk = post( + reverse( + 'api:notification_template_copy', kwargs={'pk': notification_template_with_encrypt.pk} + ), {'name': 'copied nt'}, alice, expect=201 + ).data['id'] + notification_template_copy = type(notification_template_with_encrypt).objects.get(pk=nt_copy_pk) + assert notification_template_copy.created_by == alice + assert notification_template_copy.name == 'copied nt' + assert notification_template_copy.organization == organization + assert (decrypt_field(notification_template_with_encrypt, 'notification_configuration', 'token') == + decrypt_field(notification_template_copy, 'notification_configuration', 'token')) + + +@pytest.mark.django_db +def test_inventory_script_copy(post, get, inventory_script, organization, alice): + assert get( + reverse('api:inventory_script_copy', kwargs={'pk': inventory_script.pk}), alice, expect=200 + ).data['can_copy'] is False + inventory_script.organization.admin_role.members.add(alice) + assert get( + reverse('api:inventory_script_copy', kwargs={'pk': inventory_script.pk}), alice, expect=200 + ).data['can_copy'] is True + is_copy_pk = post( + reverse('api:inventory_script_copy', kwargs={'pk': inventory_script.pk}), + {'name': 'copied inv script'}, alice, expect=201 + ).data['id'] + inventory_script_copy = type(inventory_script).objects.get(pk=is_copy_pk) + assert inventory_script_copy.created_by == alice + assert inventory_script_copy.name == 'copied inv script' + assert inventory_script_copy.organization == organization diff --git a/docs/resource_copy.md b/docs/resource_copy.md new file mode 100644 index 0000000000..da85d55225 --- /dev/null +++ b/docs/resource_copy.md @@ -0,0 +1,166 @@ +Starting from Tower 3.3 and API v2, user are able to copy some existing resource objects to quickly +create new resource objects via POSTing to corresponding `/copy/` endpoint. A new `CopyAPIView` class +is introduced as the base view class for `/copy/` endpoints. It mimics the process of manually fetching +fields from the existing object to create a new object, plus the ability to automatically detect sub +structures of existing objects and make a background task-based deep copy when necessary. + +## Usage +If an AWX resource is copiable, all of its object detail API views will have a related URL field +`"copy"`, which has form `/api////copy/`. GET to this endpoint +will return `can_copy`, which is a boolean indicating whether the current user can execute a copy +operation; POST to this endpoint actually copies the resource object. One field `name` is required +which will later be used as the name of the created copy. Upon success, 201 will be returned, along +with the created copy. + +For some resources like credential, the copy process is not time-consuming, thus the entire copy +process will take place in the request-response cycle, and the created object copy is returned as +POST response. + +For some other resources like inventory, the copy process can take longer, depending on the number +of sub-objects to copy (will explain later). Thus, although the created copy will be returned, the +copy process is not finished yet. All sub-objects (like all hosts and groups of an inventory) will +not be created until after the background copy task is finished in success. + +Currently the available list of copiable resources are: + +- job templates +- projects +- inventories +- workflows +- credentials +- notifications +- inventory scripts + +For most of the resources above, only the object to be copied itself will be copied; For some resources +like inventories, however, sub resources belonging to the resource will also be copied to maintain the +full functionality of the copied new resource. In specific: + +- When an inventory is copied, all its hosts, groups and inventory sources are copied. +- When a workflow job template is copied, all its workflow job template nodes are copied. + +## How to add a copy end-point for a resource +The copy behavior of different resources largely follow the same pattern, therefore a unified way of +enabling copy capability for resources is available for developers: + +Firstly, create a `/copy/` url endpoint for the target resource. + +Secondly, create a view class as handler to `/copy/` endpoint. This view class should be subclassed +from `awx.api.generics.CopyAPIView`. Here is an example: +```python +class JobTemplateCopy(CopyAPIView): + + model = JobTemplate + copy_return_serializer_class = JobTemplateSerializer +``` +Note the above example declares a custom class attribute `copy_return_serializer_class`. This attribute +is used by `CopyAPIView` to render the created copy in POST response, so in most cases the value should +be the same as `serializer_class` of corresponding resource detail view, like here the value is the +`serializer_class` of `JobTemplateDetail`. + +Thirdly, for the underlying model of the resource, Add 2 macros, `FIELDS_TO_PRESERVE_AT_COPY` and +`FIELDS_TO_DISCARD_AT_COPY`, as needed. Here is an example: +```python +class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, ResourceMixin): + ''' + A job template is a reusable job definition for applying a project (with + playbook) to an inventory source with a given credential. + ''' + FIELDS_TO_PRESERVE_AT_COPY = [ + 'labels', 'instance_groups', 'credentials', 'survey_spec' + ] + FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential'] +``` +When copying a resource object, basically all fields necessary for creating a new resource (fields +composing a valid POST body for creating new resources) are extracted from the original object and +used to create the copy. + +However, sometimes we need more fields to be copied, like `credentials` of a job template, which +cannot be provided during creation. In this case we list such fields in `FIELDS_TO_PRESERVE_AT_COPY` +so that these fields won't be missed. + +On the other hand, sometimes we do not want to include some fields provided in create POST body, +like `vault_credential` and `credential` fields used for creating a job template, which do not have +tangible field correspondence in `JobTemplate` model. In this case we list such fields in +`FIELDS_TO_DISCARD_AT_COPY` so that those fields won't be included. + +For models that will be part of a deep copy, like hosts and workflow job template nodes, the related +POST body for creating a new object is not available. Therefore all necessary fields for creating +a new resource should also be included in `FIELDS_TO_PRESERVE_AT_COPY`. + +Lastly, unit test copy behavior of the new endpoint in `/awx/main/tests/functional/test_copy.py` and +update docs (like this doc). + +Fields in `FIELDS_TO_PRESERVE_AT_COPY` must be solid model fields, while fields in +`FIELDS_TO_DISCARD_AT_COPY` do not need to be. Note there are hidden fields not visible from model +definition, namely reverse relationships and fields inherited from super classes or mix-ins. A help +script `tools/scripts/list_fields.py` is available to inspect a model and list details of all its +available fields. +``` +# In shell_plus +>>> from list_fields import pretty_print_model_fields +>>> pretty_print_model_fields(JobTemplate) +``` + +`CopyAPIView` will automatically detect sub objects of an object, and do a deep copy of all sub objects +as a background celery task. There are sometimes permission issues with sub object copy. For example, +when copying nodes of a workflow job template, there are cases where the user performing copy has no use +permission of related credential and inventory of some nodes, and it is desired those fields will be +`None`. In order to do that, developer should provide a static method `deep_copy_permission_check_func` +under corresponding specific copy view. Like +```python +class WorkflowJobTemplateCopy(WorkflowsEnforcementMixin, CopyAPIView): + + model = WorkflowJobTemplate + copy_return_serializer_class = WorkflowJobTemplateSerializer + + # Other code + + @staticmethod + def deep_copy_permission_check_func(user, new_objs): + # method body + + # Other code +``` +Static method `deep_copy_permission_check_func` must have and only have two arguments: `user`, the +user performing the copy; `new_objs`, a list of all sub objects of the created copy. Sub objects in +`new_objs` are initially populated disregarding any permission constraints, developer shall check +`user`'s permission against these new sub objects and react like unlink related objects or sending +warning logs. `deep_copy_permission_check_func` should not return anything. + +Lastly, macro `REENCRYPTION_BLACKLIST_AT_COPY` is available as part of a model definition. It is a +list of field names which will escape re-encryption during copy. For example, `extra_data` field +of workflow job template nodes. + +## Acceptance Criteria +* Credentials should be able to copy themselves. The behavior of copying credential A shall be exactly + the same as creating a credential B with all needed fields for creation coming from credential A. +* Inventories should be able to copy themselves. The behavior of copying inventory A shall be exactly + the same as creating an inventory B with all needed fields for creation coming from inventory A. Other + than that, inventory B should inherit A's `instance_groups`, and have exactly the same host and group + structures as A. +* Inventory scripts should be able to copy themselves. The behavior of copying inventory script A + shall be exactly the same as creating an inventory script B with all needed fields for creation + coming from inventory script A. +* Job templates should be able to copy themselves. The behavior of copying job template A + shall be exactly the same as creating a job template B with all needed fields for creation + coming from job template A. Other than that, job template B should inherit A's `labels`, + `instance_groups`, `credentials` and `survey_spec`. +* Notification templates should be able to copy themselves. The behavior of copying notification + template A shall be exactly the same as creating a notification template B with all needed fields + for creation coming from notification template A. +* Projects should be able to copy themselves. The behavior of copying project A shall be the + same as creating a project B with all needed fields for creation coming from project A, except for + `local_path`, which will be populated by triggered project update. Other than that, project B + should inherit A's `labels`, `instance_groups` and `credentials`. +* Workflow Job templates should be able to copy themselves. The behavior of copying workflow job + template A shall be exactly the same as creating a workflow job template B with all needed fields + for creation coming from workflow job template A. Other than that, workflow job template B should + inherit A's `labels`, `instance_groups`, `credentials` and `survey_spec`, and have exactly the + same workflow job template node structure as A. +* In all copy processes, `name` field of the created copy of the original object should be able to + customize in the POST body. +* The permission for a user to make a copy for an existing resource object should be the same as the + permission for a user to create a brand new resource object using fields from the existing object. +* The RBAC behavior of original workflow job template `/copy/` should be pertained. That is, if the + user has no necessary permission to the related project and credential of a workflow job template + node, the copied workflow job template node should have those fields empty. diff --git a/tools/scripts/list_fields.py b/tools/scripts/list_fields.py new file mode 100755 index 0000000000..1e637e9c1b --- /dev/null +++ b/tools/scripts/list_fields.py @@ -0,0 +1,46 @@ +__all__ = ['pretty_print_model_fields'] + + +def _get_class_full_name(cls_): + return cls_.__module__ + '.' + cls_.__name__ + + +class _ModelFieldRow(object): + + def __init__(self, field): + self.field = field + self.name = field.name + self.type_ = _get_class_full_name(type(field)) + if self.field.many_to_many\ + or self.field.many_to_one\ + or self.field.one_to_many\ + or self.field.one_to_one: + self.related_model = _get_class_full_name(self.field.remote_field.model) + else: + self.related_model = 'N/A' + + def pretty_print(self, max_name_len, max_type_len, max_rel_model_len): + row = [] + row.append(self.name) + row.append(' ' * (max_name_len - len(self.name))) + row.append('|') + row.append(self.type_) + row.append(' ' * (max_type_len - len(self.type_))) + row.append('|') + row.append(self.related_model) + row.append(' ' * (max_rel_model_len - len(self.related_model))) + print(''.join(row)) + + +def pretty_print_model_fields(model): + field_info_rows = [] + max_lens = [0, 0, 0] + for field in model._meta.get_fields(): + field_info_rows.append(_ModelFieldRow(field)) + max_lens[0] = max(max_lens[0], len(field_info_rows[-1].name)) + max_lens[1] = max(max_lens[1], len(field_info_rows[-1].type_)) + max_lens[2] = max(max_lens[2], len(field_info_rows[-1].related_model)) + print('=' * (sum(max_lens) + len(max_lens) - 1)) + for row in field_info_rows: + row.pretty_print(*max_lens) + print('=' * (sum(max_lens) + len(max_lens) - 1)) From 286a70f2cac304aad7122160c96528c2cfc7b2c1 Mon Sep 17 00:00:00 2001 From: Jim Ladd Date: Tue, 14 Nov 2017 17:26:58 -0500 Subject: [PATCH 02/82] Add support for multi-file injection in custom creds --- awx/main/fields.py | 5 ++--- awx/main/models/credential/__init__.py | 17 ++++++++++------- awx/main/tests/functional/test_credential.py | 2 ++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 777836ebf3..893051492f 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -695,11 +695,10 @@ class CredentialTypeInjectorField(JSONSchemaField): 'properties': { 'file': { 'type': 'object', - 'properties': { - 'template': {'type': 'string'}, + 'patternProperties': { + '^template\.[a-zA-Z_]+$': {'type': 'string'}, }, 'additionalProperties': False, - 'required': ['template'], }, 'env': { 'type': 'object', diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index face2befdb..5f82c79a91 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -594,9 +594,11 @@ class CredentialType(CommonModelNameNotUnique): return class TowerNamespace: - filename = None + pass tower_namespace = TowerNamespace() + filename_namespace = TowerNamespace() + tower_namespace.filename = filename_namespace # maintain a normal namespace for building the ansible-playbook arguments (env and args) namespace = {'tower': tower_namespace} @@ -622,17 +624,18 @@ class CredentialType(CommonModelNameNotUnique): if len(value): namespace[field_name] = value - file_tmpl = self.injectors.get('file', {}).get('template') - if file_tmpl is not None: - # If a file template is provided, render the file and update the - # special `tower` template namespace so the filename can be - # referenced in other injectors + file_tmpls = self.injectors.get('file', {}) + # If any file templates are provided, render the files and update the + # special `tower` template namespace so the filename can be + # referenced in other injectors + for file_label, file_tmpl in file_tmpls.items(): data = Template(file_tmpl).render(**namespace) _, path = tempfile.mkstemp(dir=private_data_dir) with open(path, 'w') as f: f.write(data) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) - namespace['tower'].filename = path + file_label = file_label.split('.')[1] + setattr(namespace['tower'].filename, file_label, path) for env_var, tmpl in self.injectors.get('env', {}).items(): if env_var.startswith('ANSIBLE_') or env_var in self.ENV_BLACKLIST: diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 1fe909c092..2bf15b3f8e 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -109,6 +109,8 @@ def test_cred_type_input_schema_validity(input_, valid): ({'file': 123}, False), ({'file': {}}, False), ({'file': {'template': '{{username}}'}}, True), + ({'file': {'template.username': '{{username}}'}}, True), + ({'file': {'template.username': '{{username}}', 'template.password': '{{pass}}'}}, True), ({'file': {'foo': 'bar'}}, False), ({'env': 123}, False), ({'env': {}}, True), From 7aa1ae69b3974167a99dabbf12eeda6f4eafdbbc Mon Sep 17 00:00:00 2001 From: Jim Ladd Date: Tue, 21 Nov 2017 11:00:31 -0500 Subject: [PATCH 03/82] Add backwards compatibility for injecting single file --- awx/main/fields.py | 2 +- awx/main/models/credential/__init__.py | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 893051492f..005ed85362 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -696,7 +696,7 @@ class CredentialTypeInjectorField(JSONSchemaField): 'file': { 'type': 'object', 'patternProperties': { - '^template\.[a-zA-Z_]+$': {'type': 'string'}, + '^template(\.[a-zA-Z_]+)?$': {'type': 'string'}, }, 'additionalProperties': False, }, diff --git a/awx/main/models/credential/__init__.py b/awx/main/models/credential/__init__.py index 5f82c79a91..86c3930299 100644 --- a/awx/main/models/credential/__init__.py +++ b/awx/main/models/credential/__init__.py @@ -597,8 +597,6 @@ class CredentialType(CommonModelNameNotUnique): pass tower_namespace = TowerNamespace() - filename_namespace = TowerNamespace() - tower_namespace.filename = filename_namespace # maintain a normal namespace for building the ansible-playbook arguments (env and args) namespace = {'tower': tower_namespace} @@ -634,8 +632,15 @@ class CredentialType(CommonModelNameNotUnique): with open(path, 'w') as f: f.write(data) os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) - file_label = file_label.split('.')[1] - setattr(namespace['tower'].filename, file_label, path) + + # determine if filename indicates single file or many + if file_label.find('.') == -1: + tower_namespace.filename = path + else: + if not hasattr(tower_namespace, 'filename'): + tower_namespace.filename = TowerNamespace() + file_label = file_label.split('.')[1] + setattr(tower_namespace.filename, file_label, path) for env_var, tmpl in self.injectors.get('env', {}).items(): if env_var.startswith('ANSIBLE_') or env_var in self.ENV_BLACKLIST: From 18178c83b3c0909e2254b13e0d1e0d871a8c75ef Mon Sep 17 00:00:00 2001 From: Jim Ladd Date: Tue, 21 Nov 2017 13:11:14 -0500 Subject: [PATCH 04/82] Validate single and multi-file injection --- awx/main/fields.py | 18 +++++++++++++++++- docs/custom_credential_types.md | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 005ed85362..6e8d3bb1f3 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -748,8 +748,24 @@ class CredentialTypeInjectorField(JSONSchemaField): class TowerNamespace: filename = None - valid_namespace['tower'] = TowerNamespace() + + # ensure either single file or multi-file syntax is used (but not both) + template_names = set(key for type_, injector in value.items() + for key, tmpl in injector.items() + if key.startswith('template')) + if 'template' in template_names and len(template_names) > 1: + raise django_exceptions.ValidationError( + _('Must use multi-file syntax when injecting multiple files'), + code='invalid', + params={'value': value}, + ) + if 'template' not in template_names: + valid_namespace['tower'].filename = TowerNamespace() + for template_name in template_names: + template_name = template_name[9:] + setattr(valid_namespace['tower'].filename, template_name, 'EXAMPLE') + for type_, injector in value.items(): for key, tmpl in injector.items(): try: diff --git a/docs/custom_credential_types.md b/docs/custom_credential_types.md index c1b5387565..33426c2011 100644 --- a/docs/custom_credential_types.md +++ b/docs/custom_credential_types.md @@ -194,7 +194,8 @@ certificate/key data: } } - +Note that the single and multi-file syntax cannot be mixed within the same +``Credential Type``. Job and Job Template Credential Assignment ------------------------------------------ @@ -326,6 +327,8 @@ When verifying acceptance we should ensure the following statements are true: * Custom `Credential Types` should support injecting both single and multiple files. (Furthermore, the new syntax for injecting multiple files should work properly even if only a single file is injected). +* Users should not be able to use the syntax for injecting single and + multiple files in the same custom credential. * The default `Credential Types` included with Tower in 3.2 should be non-editable/readonly and cannot be deleted by any user. * Stored `Credential` values for _all_ types should be consistent before and From 4b13bcdce2363ca91b476035a5ba4a7bd14812cc Mon Sep 17 00:00:00 2001 From: Jim Ladd Date: Tue, 21 Nov 2017 14:43:10 -0500 Subject: [PATCH 05/82] Update tests for custom credentials --- awx/main/fields.py | 2 +- awx/main/tests/functional/test_credential.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index 6e8d3bb1f3..d63d1a30b8 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -763,7 +763,7 @@ class CredentialTypeInjectorField(JSONSchemaField): if 'template' not in template_names: valid_namespace['tower'].filename = TowerNamespace() for template_name in template_names: - template_name = template_name[9:] + template_name = template_name.split('.')[1] setattr(valid_namespace['tower'].filename, template_name, 'EXAMPLE') for type_, injector in value.items(): diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 2bf15b3f8e..37609cd222 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -107,10 +107,11 @@ def test_cred_type_input_schema_validity(input_, valid): ({}, True), ({'invalid-injector': {}}, False), ({'file': 123}, False), - ({'file': {}}, 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), From 44d223b6c96fe2f9269b123ea97b6fe878e4fb14 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 23 Jan 2018 14:07:10 -0500 Subject: [PATCH 06/82] add fields for team and organization saml attribute mappings --- awx/sso/conf.py | 2 +- .../auth-form/sub-forms/auth-saml.form.js | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/awx/sso/conf.py b/awx/sso/conf.py index 2fdf412d82..d57223d9ef 100644 --- a/awx/sso/conf.py +++ b/awx/sso/conf.py @@ -1185,7 +1185,7 @@ register( field_class=fields.SAMLTeamAttrField, allow_null=True, default=None, - label=_('SAML Team Map'), + label=_('SAML Team Attribute Mapping'), help_text=_('Used to translate user team membership into Tower.'), category=_('SAML'), category_slug='saml', diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js index c7c8cc289d..0a6903190b 100644 --- a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js @@ -71,15 +71,28 @@ export default ['i18n', function(i18n) { codeMirror: true, class: 'Form-textAreaLabel Form-formGroup--fullWidth' }, + SOCIAL_AUTH_SAML_ORGANIZATION_ATTR: { + type: 'textarea', + reset: 'SOCIAL_AUTH_SAML_ORGANIZATION_ATTR', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth' + }, SOCIAL_AUTH_SAML_TEAM_MAP: { type: 'textarea', reset: 'SOCIAL_AUTH_SAML_TEAM_MAP', rows: 6, codeMirror: true, class: 'Form-textAreaLabel Form-formGroup--fullWidth' - } + }, + SOCIAL_AUTH_SAML_TEAM_ATTR: { + type: 'textarea', + reset: 'SOCIAL_AUTH_SAML_TEAM_ATTR', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth' + }, }, - buttons: { reset: { ngShow: '!user_is_system_auditor', From ed138fccf6b2baaac55318ebf25a26fb3c245074 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Mon, 29 Jan 2018 20:14:21 -0500 Subject: [PATCH 07/82] add forms + select for additional ldap servers --- .../configuration-auth.controller.js | 155 ++++++++++++++---- .../auth-form/configuration-auth.partial.html | 116 ++++++++----- .../auth-form/sub-forms/auth-ldap1.form.js | 108 ++++++++++++ .../auth-form/sub-forms/auth-ldap2.form.js | 108 ++++++++++++ .../auth-form/sub-forms/auth-ldap3.form.js | 108 ++++++++++++ .../auth-form/sub-forms/auth-ldap4.form.js | 108 ++++++++++++ .../auth-form/sub-forms/auth-ldap5.form.js | 108 ++++++++++++ .../configuration/configuration.block.less | 11 ++ .../configuration/configuration.controller.js | 29 +++- awx/ui/client/src/configuration/main.js | 10 ++ 10 files changed, 780 insertions(+), 81 deletions(-) create mode 100644 awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap1.form.js create mode 100644 awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap2.form.js create mode 100644 awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap3.form.js create mode 100644 awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap4.form.js create mode 100644 awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap5.form.js diff --git a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js index 5cd9e72a11..5b80e40ddb 100644 --- a/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js +++ b/awx/ui/client/src/configuration/auth-form/configuration-auth.controller.js @@ -17,6 +17,11 @@ export default [ 'configurationGithubTeamForm', 'configurationGoogleForm', 'configurationLdapForm', + 'configurationLdap1Form', + 'configurationLdap2Form', + 'configurationLdap3Form', + 'configurationLdap4Form', + 'configurationLdap5Form', 'configurationRadiusForm', 'configurationTacacsForm', 'configurationSamlForm', @@ -39,6 +44,11 @@ export default [ configurationGithubTeamForm, configurationGoogleForm, configurationLdapForm, + configurationLdap1Form, + configurationLdap2Form, + configurationLdap3Form, + configurationLdap4Form, + configurationLdap5Form, configurationRadiusForm, configurationTacacsForm, configurationSamlForm, @@ -55,6 +65,8 @@ export default [ var formTracker = $scope.$parent.vm.formTracker; var dropdownValue = 'azure'; var activeAuthForm = 'azure'; + var ldapDropdownValue = ''; + let codeInputInitialized = false; // Default active form @@ -62,10 +74,16 @@ export default [ formTracker.setCurrentAuth(activeAuthForm); } - var activeForm = function() { + const getActiveAuthForm = () => { + if (authVm.dropdownValue === 'ldap') { + return `ldap${authVm.ldapDropdownValue}`; + } + return authVm.dropdownValue; + }; + var activeForm = function() { if(!$scope.$parent[formTracker.currentFormName()].$dirty) { - authVm.activeAuthForm = authVm.dropdownValue; + authVm.activeAuthForm = getActiveAuthForm(); formTracker.setCurrentAuth(authVm.activeAuthForm); startCodeMirrors(); } else { @@ -78,7 +96,7 @@ export default [ onClick: function() { $scope.$parent.vm.populateFromApi(); $scope.$parent[formTracker.currentFormName()].$setPristine(); - authVm.activeAuthForm = authVm.dropdownValue; + authVm.activeAuthForm = getActiveAuthForm(); formTracker.setCurrentAuth(authVm.activeAuthForm); $('#FormModal-dialog').dialog('close'); } @@ -89,7 +107,7 @@ export default [ .then(function() { $scope.$parent[formTracker.currentFormName()].$setPristine(); $scope.$parent.vm.populateFromApi(); - authVm.activeAuthForm = authVm.dropdownValue; + authVm.activeAuthForm = getActiveAuthForm(); formTracker.setCurrentAuth(authVm.activeAuthForm); $('#FormModal-dialog').dialog('close'); }); @@ -100,11 +118,12 @@ export default [ $scope.$parent.vm.triggerModal(msg, title, buttons); } formTracker.setCurrentAuth(authVm.activeAuthForm); + authVm.ldapSelected = (authVm.activeAuthForm.indexOf('ldap') !== -1); }; var dropdownOptions = [ {label: i18n._('Azure AD'), value: 'azure'}, - {label: i18n._('GitHub'), value: 'github'}, + {label: i18n._('GitHub'), value: 'github'}, {label: i18n._('GitHub Org'), value: 'github_org'}, {label: i18n._('GitHub Team'), value: 'github_team'}, {label: i18n._('Google OAuth2'), value: 'google_oauth'}, @@ -114,48 +133,97 @@ export default [ {label: i18n._('TACACS+'), value: 'tacacs'} ]; + var ldapDropdownOptions = [ + {label: i18n._('Default'), value: ''}, + {label: i18n._('LDAP 1 (Optional)'), value: '1'}, + {label: i18n._('LDAP 2 (Optional)'), value: '2'}, + {label: i18n._('LDAP 3 (Optional)'), value: '3'}, + {label: i18n._('LDAP 4 (Optional)'), value: '4'}, + {label: i18n._('LDAP 5 (Optional)'), value: '5'}, + ]; + CreateSelect2({ element: '#configure-dropdown-nav', multiple: false, }); - var authForms = [{ + CreateSelect2({ + element: '#configure-ldap-dropdown', + multiple: false, + }); + + var authForms = [ + { formDef: configurationAzureForm, id: 'auth-azure-form', name: 'azure' - }, { + }, + { formDef: configurationGithubForm, id: 'auth-github-form', name: 'github' - }, { + }, + { formDef: configurationGithubOrgForm, id: 'auth-github-org-form', name: 'github_org' - }, { + }, + { formDef: configurationGithubTeamForm, id: 'auth-github-team-form', name: 'github_team' - }, { + }, + { formDef: configurationGoogleForm, id: 'auth-google-form', name: 'google_oauth' - }, { - formDef: configurationLdapForm, - id: 'auth-ldap-form', - name: 'ldap' - }, { + }, + { formDef: configurationRadiusForm, id: 'auth-radius-form', name: 'radius' - }, { + }, + { formDef: configurationTacacsForm, id: 'auth-tacacs-form', name: 'tacacs' - }, { + }, + { formDef: configurationSamlForm, id: 'auth-saml-form', name: 'saml' - }, ]; + }, + { + formDef: configurationLdapForm, + id: 'auth-ldap-form', + name: 'ldap' + }, + { + formDef: configurationLdap1Form, + id: 'auth-ldap1-form', + name: 'ldap1' + }, + { + formDef: configurationLdap2Form, + id: 'auth-ldap2-form', + name: 'ldap2' + }, + { + formDef: configurationLdap3Form, + id: 'auth-ldap3-form', + name: 'ldap3' + }, + { + formDef: configurationLdap4Form, + id: 'auth-ldap4-form', + name: 'ldap4' + }, + { + formDef: configurationLdap5Form, + id: 'auth-ldap5-form', + name: 'ldap5' + }, + ]; var forms = _.pluck(authForms, 'formDef'); _.each(forms, function(form) { @@ -179,10 +247,8 @@ export default [ form.buttons.save.disabled = $rootScope.user_is_system_auditor; }); - function startCodeMirrors(key){ - var form = _.find(authForms, function(f){ - return f.name === $scope.authVm.activeAuthForm; - }); + function startCodeMirrors(key) { + var form = _.find(authForms, f => f.name === $scope.authVm.activeAuthForm); if(!key){ // Attach codemirror to fields that need it @@ -246,9 +312,23 @@ export default [ // Flag to avoid re-rendering and breaking Select2 dropdowns on tab switching var dropdownRendered = false; - function populateLDAPGroupType(flag){ - if($scope.$parent.AUTH_LDAP_GROUP_TYPE !== null) { - $scope.$parent.AUTH_LDAP_GROUP_TYPE = _.find($scope.$parent.AUTH_LDAP_GROUP_TYPE_options, { value: $scope.$parent.AUTH_LDAP_GROUP_TYPE }); + function populateLDAPGroupType(flag, index = null){ + let groupPropName; + let groupOptionsPropName; + let selectElementId; + + if (index) { + groupPropName = `AUTH_LDAP_${index}_GROUP_TYPE`; + groupOptionsPropName = `${groupPropName}_options`; + selectElementId = `#configuration_ldap${index}_template_${groupPropName}`; + } else { + groupPropName = 'AUTH_LDAP_GROUP_TYPE'; + groupOptionsPropName = `${groupPropName}_options`; + selectElementId = `#configuration_ldap_template_${groupPropName}`; + } + + if($scope.$parent[groupPropName] !== null) { + $scope.$parent[groupPropName] = _.find($scope[groupOptionsPropName], { value: $scope.$parent[groupPropName] }); } if(flag !== undefined){ @@ -258,7 +338,7 @@ export default [ if(!dropdownRendered) { dropdownRendered = true; CreateSelect2({ - element: '#configuration_ldap_template_AUTH_LDAP_GROUP_TYPE', + element: selectElementId, multiple: false, placeholder: i18n._('Select group types'), }); @@ -284,13 +364,12 @@ export default [ } } - $scope.$on('AUTH_LDAP_GROUP_TYPE_populated', function(e, data, flag) { - populateLDAPGroupType(flag); - }); - - $scope.$on('TACACSPLUS_AUTH_PROTOCOL_populated', function(e, data, flag) { - populateTacacsProtocol(flag); - }); + $scope.$on('AUTH_LDAP_GROUP_TYPE_populated', (e, data, flag) => populateLDAPGroupType(flag)); + $scope.$on('AUTH_LDAP_1_GROUP_TYPE_populated', (e, data, flag) => populateLDAPGroupType(flag, 1)); + $scope.$on('AUTH_LDAP_2_GROUP_TYPE_populated', (e, data, flag) => populateLDAPGroupType(flag, 2)); + $scope.$on('AUTH_LDAP_3_GROUP_TYPE_populated', (e, data, flag) => populateLDAPGroupType(flag, 3)); + $scope.$on('AUTH_LDAP_4_GROUP_TYPE_populated', (e, data, flag) => populateLDAPGroupType(flag, 4)); + $scope.$on('AUTH_LDAP_5_GROUP_TYPE_populated', (e, data, flag) => populateLDAPGroupType(flag, 5)); $scope.$on('$locationChangeStart', (event, url) => { let parts = url.split('/'); @@ -311,6 +390,12 @@ export default [ } populateLDAPGroupType(false); + populateLDAPGroupType(false, 1); + populateLDAPGroupType(false, 2); + populateLDAPGroupType(false, 3); + populateLDAPGroupType(false, 4); + populateLDAPGroupType(false, 5); + populateTacacsProtocol(false); }); @@ -328,7 +413,9 @@ export default [ activeAuthForm: activeAuthForm, authForms: authForms, dropdownOptions: dropdownOptions, - dropdownValue: dropdownValue + dropdownValue: dropdownValue, + ldapDropdownValue: ldapDropdownValue, + ldapDropdownOptions: ldapDropdownOptions, }); } ]; diff --git a/awx/ui/client/src/configuration/auth-form/configuration-auth.partial.html b/awx/ui/client/src/configuration/auth-form/configuration-auth.partial.html index e1ffb3d9f4..2bfa19b401 100644 --- a/awx/ui/client/src/configuration/auth-form/configuration-auth.partial.html +++ b/awx/ui/client/src/configuration/auth-form/configuration-auth.partial.html @@ -1,63 +1,89 @@
+
Sub Category
- + +
+ +
+
LDAP Server
+
+ +
-
-
-
-
-
-
-
-
+
-
-
-
-
+
+
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
+
+
+
-
-
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap1.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap1.form.js new file mode 100644 index 0000000000..8430fecee3 --- /dev/null +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap1.form.js @@ -0,0 +1,108 @@ +/************************************************* + * Copyright (c) 2016 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['i18n', function(i18n) { + return { + // editTitle: 'Authorization Configuration', + name: 'configuration_ldap1_template', + showActions: true, + showHeader: false, + + fields: { + AUTH_LDAP_1_SERVER_URI: { + type: 'text', + reset: 'AUTH_LDAP_1_SERVER_URI' + }, + AUTH_LDAP_1_BIND_DN: { + type: 'text', + reset: 'AUTH_LDAP_1_BIND_DN' + }, + AUTH_LDAP_1_BIND_PASSWORD: { + type: 'sensitive', + hasShowInputButton: true, + }, + AUTH_LDAP_1_USER_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_1_USER_SEARCH' + }, + AUTH_LDAP_1_GROUP_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_1_GROUP_SEARCH' + }, + AUTH_LDAP_1_USER_DN_TEMPLATE: { + type: 'text', + reset: 'AUTH_LDAP_1_USER_DN_TEMPLATE' + }, + AUTH_LDAP_1_USER_ATTR_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_1_USER_ATTR_MAP', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_1_GROUP_TYPE: { + type: 'select', + reset: 'AUTH_LDAP_1_GROUP_TYPE', + ngOptions: 'group.label for group in AUTH_LDAP_1_GROUP_TYPE_options track by group.value', + }, + AUTH_LDAP_1_REQUIRE_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_1_REQUIRE_GROUP' + }, + AUTH_LDAP_1_DENY_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_1_DENY_GROUP' + }, + AUTH_LDAP_1_START_TLS: { + type: 'toggleSwitch' + }, + AUTH_LDAP_1_USER_FLAGS_BY_GROUP: { + type: 'textarea', + reset: 'AUTH_LDAP_1_USER_FLAGS_BY_GROUP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_1_ORGANIZATION_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_1_ORGANIZATION_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_1_TEAM_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_1_TEAM_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + } + }, + + buttons: { + reset: { + ngShow: '!user_is_system_auditor', + ngClick: 'vm.resetAllConfirm()', + label: i18n._('Revert all to default'), + class: 'Form-resetAll' + }, + cancel: { + ngClick: 'vm.formCancel()', + }, + save: { + ngClick: 'vm.formSave()', + ngDisabled: "!ldap_auth || configuration_ldap1_template_form.$invalid || configuration_ldap1_template_form.$pending" + } + } + }; +} +]; diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap2.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap2.form.js new file mode 100644 index 0000000000..09230cb802 --- /dev/null +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap2.form.js @@ -0,0 +1,108 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['i18n', function(i18n) { + return { + // editTitle: 'Authorization Configuration', + name: 'configuration_ldap2_template', + showActions: true, + showHeader: false, + + fields: { + AUTH_LDAP_2_SERVER_URI: { + type: 'text', + reset: 'AUTH_LDAP_2_SERVER_URI' + }, + AUTH_LDAP_2_BIND_DN: { + type: 'text', + reset: 'AUTH_LDAP_2_BIND_DN' + }, + AUTH_LDAP_2_BIND_PASSWORD: { + type: 'sensitive', + hasShowInputButton: true, + }, + AUTH_LDAP_2_USER_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_2_USER_SEARCH' + }, + AUTH_LDAP_2_GROUP_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_2_GROUP_SEARCH' + }, + AUTH_LDAP_2_USER_DN_TEMPLATE: { + type: 'text', + reset: 'AUTH_LDAP_2_USER_DN_TEMPLATE' + }, + AUTH_LDAP_2_USER_ATTR_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_2_USER_ATTR_MAP', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_2_GROUP_TYPE: { + type: 'select', + reset: 'AUTH_LDAP_2_GROUP_TYPE', + ngOptions: 'group.label for group in AUTH_LDAP_2_GROUP_TYPE_options track by group.value', + }, + AUTH_LDAP_2_REQUIRE_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_2_REQUIRE_GROUP' + }, + AUTH_LDAP_2_DENY_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_2_DENY_GROUP' + }, + AUTH_LDAP_2_START_TLS: { + type: 'toggleSwitch' + }, + AUTH_LDAP_2_USER_FLAGS_BY_GROUP: { + type: 'textarea', + reset: 'AUTH_LDAP_2_USER_FLAGS_BY_GROUP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_2_ORGANIZATION_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_2_ORGANIZATION_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_2_TEAM_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_2_TEAM_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + } + }, + + buttons: { + reset: { + ngShow: '!user_is_system_auditor', + ngClick: 'vm.resetAllConfirm()', + label: i18n._('Revert all to default'), + class: 'Form-resetAll' + }, + cancel: { + ngClick: 'vm.formCancel()', + }, + save: { + ngClick: 'vm.formSave()', + ngDisabled: "!ldap_auth || configuration_ldap2_template_form.$invalid || configuration_ldap2_template_form.$pending" + } + } + }; +} +]; diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap3.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap3.form.js new file mode 100644 index 0000000000..9c26c22829 --- /dev/null +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap3.form.js @@ -0,0 +1,108 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['i18n', function(i18n) { + return { + // editTitle: 'Authorization Configuration', + name: 'configuration_ldap3_template', + showActions: true, + showHeader: false, + + fields: { + AUTH_LDAP_3_SERVER_URI: { + type: 'text', + reset: 'AUTH_LDAP_3_SERVER_URI' + }, + AUTH_LDAP_3_BIND_DN: { + type: 'text', + reset: 'AUTH_LDAP_3_BIND_DN' + }, + AUTH_LDAP_3_BIND_PASSWORD: { + type: 'sensitive', + hasShowInputButton: true, + }, + AUTH_LDAP_3_USER_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_3_USER_SEARCH' + }, + AUTH_LDAP_3_GROUP_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_3_GROUP_SEARCH' + }, + AUTH_LDAP_3_USER_DN_TEMPLATE: { + type: 'text', + reset: 'AUTH_LDAP_3_USER_DN_TEMPLATE' + }, + AUTH_LDAP_3_USER_ATTR_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_3_USER_ATTR_MAP', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_3_GROUP_TYPE: { + type: 'select', + reset: 'AUTH_LDAP_3_GROUP_TYPE', + ngOptions: 'group.label for group in AUTH_LDAP_3_GROUP_TYPE_options track by group.value', + }, + AUTH_LDAP_3_REQUIRE_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_3_REQUIRE_GROUP' + }, + AUTH_LDAP_3_DENY_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_3_DENY_GROUP' + }, + AUTH_LDAP_3_START_TLS: { + type: 'toggleSwitch' + }, + AUTH_LDAP_3_USER_FLAGS_BY_GROUP: { + type: 'textarea', + reset: 'AUTH_LDAP_3_USER_FLAGS_BY_GROUP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_3_ORGANIZATION_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_3_ORGANIZATION_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_3_TEAM_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_3_TEAM_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + } + }, + + buttons: { + reset: { + ngShow: '!user_is_system_auditor', + ngClick: 'vm.resetAllConfirm()', + label: i18n._('Revert all to default'), + class: 'Form-resetAll' + }, + cancel: { + ngClick: 'vm.formCancel()', + }, + save: { + ngClick: 'vm.formSave()', + ngDisabled: "!ldap_auth || configuration_ldap3_template_form.$invalid || configuration_ldap3_template_form.$pending" + } + } + }; +} +]; diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap4.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap4.form.js new file mode 100644 index 0000000000..95c9f6d175 --- /dev/null +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap4.form.js @@ -0,0 +1,108 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['i18n', function(i18n) { + return { + // editTitle: 'Authorization Configuration', + name: 'configuration_ldap4_template', + showActions: true, + showHeader: false, + + fields: { + AUTH_LDAP_4_SERVER_URI: { + type: 'text', + reset: 'AUTH_LDAP_4_SERVER_URI' + }, + AUTH_LDAP_4_BIND_DN: { + type: 'text', + reset: 'AUTH_LDAP_4_BIND_DN' + }, + AUTH_LDAP_4_BIND_PASSWORD: { + type: 'sensitive', + hasShowInputButton: true, + }, + AUTH_LDAP_4_USER_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_4_USER_SEARCH' + }, + AUTH_LDAP_4_GROUP_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_4_GROUP_SEARCH' + }, + AUTH_LDAP_4_USER_DN_TEMPLATE: { + type: 'text', + reset: 'AUTH_LDAP_4_USER_DN_TEMPLATE' + }, + AUTH_LDAP_4_USER_ATTR_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_4_USER_ATTR_MAP', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_4_GROUP_TYPE: { + type: 'select', + reset: 'AUTH_LDAP_4_GROUP_TYPE', + ngOptions: 'group.label for group in AUTH_LDAP_4_GROUP_TYPE_options track by group.value', + }, + AUTH_LDAP_4_REQUIRE_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_4_REQUIRE_GROUP' + }, + AUTH_LDAP_4_DENY_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_4_DENY_GROUP' + }, + AUTH_LDAP_4_START_TLS: { + type: 'toggleSwitch' + }, + AUTH_LDAP_4_USER_FLAGS_BY_GROUP: { + type: 'textarea', + reset: 'AUTH_LDAP_4_USER_FLAGS_BY_GROUP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_4_ORGANIZATION_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_4_ORGANIZATION_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_4_TEAM_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_4_TEAM_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + } + }, + + buttons: { + reset: { + ngShow: '!user_is_system_auditor', + ngClick: 'vm.resetAllConfirm()', + label: i18n._('Revert all to default'), + class: 'Form-resetAll' + }, + cancel: { + ngClick: 'vm.formCancel()', + }, + save: { + ngClick: 'vm.formSave()', + ngDisabled: "!ldap_auth || configuration_ldap4_template_form.$invalid || configuration_ldap4_template_form.$pending" + } + } + }; +} +]; diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap5.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap5.form.js new file mode 100644 index 0000000000..fa93437367 --- /dev/null +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-ldap5.form.js @@ -0,0 +1,108 @@ +/************************************************* + * Copyright (c) 2018 Ansible, Inc. + * + * All Rights Reserved + *************************************************/ + +export default ['i18n', function(i18n) { + return { + // editTitle: 'Authorization Configuration', + name: 'configuration_ldap5_template', + showActions: true, + showHeader: false, + + fields: { + AUTH_LDAP_5_SERVER_URI: { + type: 'text', + reset: 'AUTH_LDAP_5_SERVER_URI' + }, + AUTH_LDAP_5_BIND_DN: { + type: 'text', + reset: 'AUTH_LDAP_5_BIND_DN' + }, + AUTH_LDAP_5_BIND_PASSWORD: { + type: 'sensitive', + hasShowInputButton: true, + }, + AUTH_LDAP_5_USER_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_5_USER_SEARCH' + }, + AUTH_LDAP_5_GROUP_SEARCH: { + type: 'textarea', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + reset: 'AUTH_LDAP_5_GROUP_SEARCH' + }, + AUTH_LDAP_5_USER_DN_TEMPLATE: { + type: 'text', + reset: 'AUTH_LDAP_5_USER_DN_TEMPLATE' + }, + AUTH_LDAP_5_USER_ATTR_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_5_USER_ATTR_MAP', + rows: 6, + codeMirror: true, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_5_GROUP_TYPE: { + type: 'select', + reset: 'AUTH_LDAP_5_GROUP_TYPE', + ngOptions: 'group.label for group in AUTH_LDAP_5_GROUP_TYPE_options track by group.value', + }, + AUTH_LDAP_5_REQUIRE_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_5_REQUIRE_GROUP' + }, + AUTH_LDAP_5_DENY_GROUP: { + type: 'text', + reset: 'AUTH_LDAP_5_DENY_GROUP' + }, + AUTH_LDAP_5_START_TLS: { + type: 'toggleSwitch' + }, + AUTH_LDAP_5_USER_FLAGS_BY_GROUP: { + type: 'textarea', + reset: 'AUTH_LDAP_5_USER_FLAGS_BY_GROUP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_5_ORGANIZATION_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_5_ORGANIZATION_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + AUTH_LDAP_5_TEAM_MAP: { + type: 'textarea', + reset: 'AUTH_LDAP_5_TEAM_MAP', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + } + }, + + buttons: { + reset: { + ngShow: '!user_is_system_auditor', + ngClick: 'vm.resetAllConfirm()', + label: i18n._('Revert all to default'), + class: 'Form-resetAll' + }, + cancel: { + ngClick: 'vm.formCancel()', + }, + save: { + ngClick: 'vm.formSave()', + ngDisabled: "!ldap_auth || configuration_ldap5_template_form.$invalid || configuration_ldap5_template_form.$pending" + } + } + }; +} +]; diff --git a/awx/ui/client/src/configuration/configuration.block.less b/awx/ui/client/src/configuration/configuration.block.less index bc6f4faa38..7cba028ef9 100644 --- a/awx/ui/client/src/configuration/configuration.block.less +++ b/awx/ui/client/src/configuration/configuration.block.less @@ -38,6 +38,13 @@ justify-content: flex-end; } +.Form-nav--ldapDropdownContainer { + align-items: center; + width: 100%; + margin: 0 0 auto auto; + display: flex; +} + .Form-nav--dropdown { width: 285px; } @@ -166,3 +173,7 @@ input#filePickerText { .LogAggregator-failedNotification{ max-width: 300px; } + +hr { + height: 1px; +} diff --git a/awx/ui/client/src/configuration/configuration.controller.js b/awx/ui/client/src/configuration/configuration.controller.js index 8fbf100eaa..fec8c23227 100644 --- a/awx/ui/client/src/configuration/configuration.controller.js +++ b/awx/ui/client/src/configuration/configuration.controller.js @@ -15,6 +15,11 @@ export default [ 'configurationGithubTeamForm', 'configurationGoogleForm', 'configurationLdapForm', + 'configurationLdap1Form', + 'configurationLdap2Form', + 'configurationLdap3Form', + 'configurationLdap4Form', + 'configurationLdap5Form', 'configurationRadiusForm', 'configurationTacacsForm', 'configurationSamlForm', @@ -34,6 +39,11 @@ export default [ configurationGithubTeamForm, configurationGoogleForm, configurationLdapForm, + configurationLdap1Form, + configurationLdap2Form, + configurationLdap3Form, + configurationLdap4Form, + configurationLdap5Form, configurationRadiusForm, configurationTacacsForm, configurationSamlForm, @@ -52,6 +62,11 @@ export default [ 'github_team': configurationGithubTeamForm, 'google_oauth': configurationGoogleForm, 'ldap': configurationLdapForm, + 'ldap1': configurationLdap1Form, + 'ldap2': configurationLdap2Form, + 'ldap3': configurationLdap3Form, + 'ldap4': configurationLdap4Form, + 'ldap5': configurationLdap5Form, 'radius': configurationRadiusForm, 'tacacs': configurationTacacsForm, 'saml': configurationSamlForm, @@ -85,9 +100,14 @@ export default [ // the ConfigurationUtils.arrayToList() // does a string.split(', ') w/ an extra space // behind the comma. + + const isLdap = (key.indexOf("AUTH_LDAP") !== -1); + const isLdapUserSearch = isLdap && (key.indexOf("USER_SEARCH") !== -1); + const isLdapGroupSearch = isLdap && (key.indexOf("GROUP_SEARCH") !== -1); + if(key === "AD_HOC_COMMANDS"){ $scope[key] = data[key]; - } else if (key === "AUTH_LDAP_USER_SEARCH" || key === "AUTH_LDAP_GROUP_SEARCH") { + } else if (isLdapUserSearch || isLdapGroupSearch) { $scope[key] = JSON.stringify(data[key]); } else { $scope[key] = ConfigurationUtils.arrayToList(data[key], key); @@ -339,7 +359,12 @@ export default [ $scope.$broadcast(key+'_reverted'); } else if($scope[key + '_field'].hasOwnProperty('codeMirror')){ - if (key === "AUTH_LDAP_USER_SEARCH" || key === "AUTH_LDAP_GROUP_SEARCH") { + const isLdap = (key.indexOf("AUTH_LDAP") !== -1); + + const isLdapUserSearch = isLdap && (key.indexOf("USER_SEARCH") !== -1); + const isLdapGroupSearch = isLdap && (key.indexOf("GROUP_SEARCH") !== -1); + + if (isLdapUserSearch || isLdapGroupSearch) { $scope[key] = '[]'; } else { $scope[key] = '{}'; diff --git a/awx/ui/client/src/configuration/main.js b/awx/ui/client/src/configuration/main.js index 40715249b1..3846354546 100644 --- a/awx/ui/client/src/configuration/main.js +++ b/awx/ui/client/src/configuration/main.js @@ -17,6 +17,11 @@ import configurationGithubOrgForm from './auth-form/sub-forms/auth-github-org.fo import configurationGithubTeamForm from './auth-form/sub-forms/auth-github-team.form'; import configurationGoogleForm from './auth-form/sub-forms/auth-google-oauth2.form'; import configurationLdapForm from './auth-form/sub-forms/auth-ldap.form.js'; +import configurationLdap1Form from './auth-form/sub-forms/auth-ldap1.form.js'; +import configurationLdap2Form from './auth-form/sub-forms/auth-ldap2.form.js'; +import configurationLdap3Form from './auth-form/sub-forms/auth-ldap3.form.js'; +import configurationLdap4Form from './auth-form/sub-forms/auth-ldap4.form.js'; +import configurationLdap5Form from './auth-form/sub-forms/auth-ldap5.form.js'; import configurationRadiusForm from './auth-form/sub-forms/auth-radius.form.js'; import configurationTacacsForm from './auth-form/sub-forms/auth-tacacs.form.js'; import configurationSamlForm from './auth-form/sub-forms/auth-saml.form'; @@ -39,6 +44,11 @@ angular.module('configuration', []) .factory('configurationGithubTeamForm', configurationGithubTeamForm) .factory('configurationGoogleForm', configurationGoogleForm) .factory('configurationLdapForm', configurationLdapForm) + .factory('configurationLdap1Form', configurationLdap1Form) + .factory('configurationLdap2Form', configurationLdap2Form) + .factory('configurationLdap3Form', configurationLdap3Form) + .factory('configurationLdap4Form', configurationLdap4Form) + .factory('configurationLdap5Form', configurationLdap5Form) .factory('configurationRadiusForm', configurationRadiusForm) .factory('configurationTacacsForm', configurationTacacsForm) .factory('configurationSamlForm', configurationSamlForm) From 4cd6a6e566d7ef7c09afa450fec050952c2d1aa0 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Tue, 30 Jan 2018 00:16:47 -0500 Subject: [PATCH 08/82] add fields for saml + 2fa --- .../auth-form/sub-forms/auth-saml.form.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js index 0a6903190b..ad103461f9 100644 --- a/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js +++ b/awx/ui/client/src/configuration/auth-form/sub-forms/auth-saml.form.js @@ -92,6 +92,27 @@ export default ['i18n', function(i18n) { codeMirror: true, class: 'Form-textAreaLabel Form-formGroup--fullWidth' }, + SOCIAL_AUTH_SAML_SECURITY_CONFIG: { + type: 'textarea', + reset: 'SOCIAL_AUTH_SAML_SECURITY_CONFIG', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + SOCIAL_AUTH_SAML_SP_EXTRA: { + type: 'textarea', + reset: 'SOCIAL_AUTH_SAML_SP_EXTRA', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, + SOCIAL_AUTH_SAML_EXTRA_DATA: { + type: 'textarea', + reset: 'SOCIAL_AUTH_SAML_EXTRA_DATA', + codeMirror: true, + rows: 6, + class: 'Form-textAreaLabel Form-formGroup--fullWidth', + }, }, buttons: { reset: { From c6d4a62263af13745389af3caf120f26b0554f33 Mon Sep 17 00:00:00 2001 From: Dane Elwell Date: Tue, 30 Jan 2018 09:47:58 +0000 Subject: [PATCH 09/82] Allow AWX projects directory to be a volume Signed-off-by: Dane Elwell --- installer/inventory | 4 ++++ installer/local_docker/tasks/standalone.yml | 2 ++ 2 files changed, 6 insertions(+) diff --git a/installer/inventory b/installer/inventory index d1a742d314..fb966933eb 100644 --- a/installer/inventory +++ b/installer/inventory @@ -90,3 +90,7 @@ pg_port=5432 #awx_container_search_domains=example.com,ansible.com # Alternate DNS servers #awx_alternate_dns_servers="10.1.2.3,10.2.3.4" + +# AWX project data folder. If you need access to the location where AWX stores the projects +# it manages from the docker host, you can set this to turn it into a volume for the container. +#project_data_dir=/var/lib/awx/projects \ No newline at end of file diff --git a/installer/local_docker/tasks/standalone.yml b/installer/local_docker/tasks/standalone.yml index f6a51f71d1..ac23f74e66 100644 --- a/installer/local_docker/tasks/standalone.yml +++ b/installer/local_docker/tasks/standalone.yml @@ -79,6 +79,7 @@ state: started restart_policy: unless-stopped image: "{{ awx_web_docker_actual_image }}" + volumes: "{{ project_data_dir + ':/var/lib/awx/projects:rw' if project_data_dir is defined else omit }}" user: root ports: - "{{ host_port }}:8052" @@ -112,6 +113,7 @@ state: started restart_policy: unless-stopped image: "{{ awx_task_docker_actual_image }}" + volumes: "{{ project_data_dir + ':/var/lib/awx/projects:rw' if project_data_dir is defined else omit }}" links: "{{ awx_task_container_links|list }}" user: root hostname: awx From 4c1dddcaf98d1909a696698d11da246e71264ecf Mon Sep 17 00:00:00 2001 From: Jim Ladd Date: Wed, 31 Jan 2018 11:22:01 -0500 Subject: [PATCH 10/82] Respond to PR feedback --- awx/main/fields.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/awx/main/fields.py b/awx/main/fields.py index d63d1a30b8..9bb257faba 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -696,7 +696,7 @@ class CredentialTypeInjectorField(JSONSchemaField): 'file': { 'type': 'object', 'patternProperties': { - '^template(\.[a-zA-Z_]+)?$': {'type': 'string'}, + '^template(\.[a-zA-Z_]+[a-zA-Z0-9_]*)?$': {'type': 'string'}, }, 'additionalProperties': False, }, @@ -751,9 +751,7 @@ class CredentialTypeInjectorField(JSONSchemaField): valid_namespace['tower'] = TowerNamespace() # ensure either single file or multi-file syntax is used (but not both) - template_names = set(key for type_, injector in value.items() - for key, tmpl in injector.items() - if key.startswith('template')) + template_names = [x for x in value.get('file', {}).keys() if x.startswith('template')] if 'template' in template_names and len(template_names) > 1: raise django_exceptions.ValidationError( _('Must use multi-file syntax when injecting multiple files'), From 2a6f6111dc27fe7fce879fbb601c606f9a3814e1 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 31 Jan 2018 13:12:54 -0500 Subject: [PATCH 11/82] add documentation for how awx uses/interacts with ansible --- docs/overview.md | 118 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/overview.md diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000000..fe417c4a14 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,118 @@ +# awx +awx provides a web interface and distributed task engine for scheduling and +running Ansible playbooks. As such, it relies heavily on the interfaces +provided by Ansible. This document provides a birds-eye view of the notable +touchpoints between awx and Ansible. + +## Terminology +awx has a variety of concepts which map to components of Ansible, or +which further abstract them to provide functionality on top of Ansible. A few +of the most notable ones are: + +### Projects +Projects represent a collection of Ansible playbooks. Most awx users create +Projects that import periodically from source control systems (such as git, +mercurial, or subversion repositories). This import is accomplished via an +ansible playbook included with awx (which makes use of the various source +control management modules in Ansible). + +### Inventories +awx manages Inventories, Groups, and Hosts, and provides a RESTful interface +that maps to static and dynamic Ansible inventories. Inventory data can +be entered into awx manually, but many users perform Inventory Syncs to import +inventory data from a variety of external sources. + +### Job Templates +A Job Template is a definition and set of parameters for running +`ansible-playbook`. If defines metadata about a given playbook run, such as: + +* a named identifier +* an associated inventory to run against +* the project and `.yml` playbook to run +* a variety of other options which map directly to ansible-playbook + arguments (extra_vars, verbosity, forks, limit, etc...) + +### Credentials +awx stores sensitive credential data which can be attached to `ansible-playbook` +processes that it runs. This data can be oriented towards SSH connection +authentication (usernames, passwords, SSH keys and passphrases), +ansible-specific prompts (such as Vault passwords), or environmental +authentication values which various Ansible modules depend on (such as setting +`AWS_ACCESS_KEY_ID` in an environment variable, or specifying +`ansible_ssh_user` as an extra variable). + +## Canonical Example +Bringing all of this terminology together, a "Getting Started using AWX" might +involve: + +* Creating a new Project that imports playbooks from e.g., a remote git repository +* Manually creating or importing an Inventory which defines where the playbook(s) will run +* Optionally, saving a Credential which contains SSH authentication details for + the host(s) where the playbook will run +* Creating a Job Template that specifies which Project and playbook to run and + where to run it (Inventory), and any necessary Credentials for e.g., SSH + authentication +* Launching the Job Template and viewing the results + +## awx's Interaction with Ansible +The touchpoints between awx and Ansible are mostly encompassed by +everything that happens *after* a job is started in awx. Specifically, this +includes: + +* Any time a Job Template is launched +* Any time a Project Update is performed +* Any time an Inventory Sync is performed +* Any time an Adhoc Command is run + +### Spawning Ansible Processes +awx relies on a handful of stable interfaces in its interaction with Ansible. +The first of these are the actual CLI for `ansible-playbook` and +`ansible-inventory`. + +When a Job Template or Project Update is run in awx, an actual +`ansible-playbook` command is composed and spawned in a pseudoterminal on one +of the servers/containers that make up the awx installation. This process runs +until completion (or until a configurable timeout), and the return code, +stdout, and stderr of the process are recorded in the awx database. Adhoc +commands work the same way, though they spawn `ansible` processes instead of +`ansible-playbook`. + +Similarly, when an Inventory Sync runs, an actual `ansible-inventory` process +runs, and its output is parsed and persisted into the awx database as Hosts and +Groups. + +awx relies on stability in CLI behavior to function properly across Ansible +releases; this includes the actual CLI arguments _and_ the behavior of task +execution and prompts (such as password, become, and Vault prompts). + +### Capturing Event Data +awx applies an Ansible callback plugin to all `ansible-playbook` and `ansible` +processes it spawns. This allows Ansible events to be captured and persisted +into the awx database; this process is what drives the "streaming" web UI +you'll see if you launch a job from the awx web interface and watch its results +appears on the screen. awx relies on stability in this plugin interface, the +heirarchy of emitted events based on strategy, and _especially_ the structure +of event data to work across Ansible releases: + +![Event Data Diagram](https://user-images.githubusercontent.com/722880/35641610-ae7f1dea-068e-11e8-84fb-0f96043d53e4.png) + +### Fact Caching +awx provides a custom fact caching implementation that allows users to store +facts for playbook runs across subsequent Job Template runs. Specifically, awx +makes use of the `jsonfile` fact cache plugin; after `ansible-playbook` runs +have exited, awx consumes the entire `jsonfile` cache and persists it in the +awx database. On subsequent Job Template runs, prior `jsonfile` caches are +restored to the local file system so the new `ansible-playbook` process makes +use of them. + +### Environment-Based Configuration +awx injects credentials and module configuration for a number of Ansible +modules via environment variables. Examples include: + +* `ANSIBLE_NET_*` and other well-known environment variables for network device authentication +* API keys and other credential values which are utilized + (`AWS_ACCESS_KEY_ID`, `GCE_EMAIL`, etc...) +* SSH-oriented configuration flags, such as `ANSIBLE_SSH_CONTROL_PATH` + +awx relies on stability in these configuration options to reliably support +credential injection for supported Ansible modules. From 0aa6c7b83f01656344f05da82ba93d308d5f8cd0 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 31 Jan 2018 15:12:59 -0500 Subject: [PATCH 12/82] remove some leaky mock.patch() that were causing sporadic test failures --- awx/main/tests/unit/test_tasks.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/awx/main/tests/unit/test_tasks.py b/awx/main/tests/unit/test_tasks.py index 2b24c18325..2c42df686a 100644 --- a/awx/main/tests/unit/test_tasks.py +++ b/awx/main/tests/unit/test_tasks.py @@ -29,6 +29,7 @@ from awx.main.models import ( ProjectUpdate, UnifiedJob, User, + Organization, build_safe_env ) @@ -204,7 +205,6 @@ class TestJobExecution: 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()), - mock.patch.object(Job, 'ansible_virtualenv_path', settings.ANSIBLE_VENV_PATH), mock.patch('awx.main.expect.run.run_pexpect', self.run_pexpect), ] for cls in (Job, AdHocCommand): @@ -267,6 +267,8 @@ class TestJobExecution: self.patches.append(patch) patch.start() + job.project = Project(organization=Organization()) + return job @property @@ -353,11 +355,9 @@ class TestGenericRun(TestJobExecution): def test_valid_custom_virtualenv(self): with TemporaryDirectory(dir=settings.BASE_VENV_PATH) as tempdir: + self.instance.project.custom_virtualenv = tempdir os.makedirs(os.path.join(tempdir, 'lib')) os.makedirs(os.path.join(tempdir, 'bin', 'activate')) - venv_patch = mock.patch.object(Job, 'ansible_virtualenv_path', tempdir) - self.patches.append(venv_patch) - venv_patch.start() self.task.run(self.pk) @@ -371,14 +371,11 @@ class TestGenericRun(TestJobExecution): assert '--ro-bind {} {}'.format(path, path) in ' '.join(args) def test_invalid_custom_virtualenv(self): - venv_patch = mock.patch.object(Job, 'ansible_virtualenv_path', '/venv/missing') - self.patches.append(venv_patch) - venv_patch.start() - with pytest.raises(Exception): + self.instance.project.custom_virtualenv = '/venv/missing' self.task.run(self.pk) - tb = self.task.update_model.call_args[-1]['result_traceback'] - assert 'a valid Python virtualenv does not exist at /venv/missing' in tb + tb = self.task.update_model.call_args[-1]['result_traceback'] + assert 'a valid Python virtualenv does not exist at /venv/missing' in tb class TestAdhocRun(TestJobExecution): From 8ddc1c61ef19f7674e6e4b5d1188d9c6294384c7 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Wed, 31 Jan 2018 15:18:11 -0500 Subject: [PATCH 13/82] add related links to the inventory and project for a schedule see: https://github.com/ansible/awx/issues/276 --- awx/api/serializers.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index d1cdcd8a90..0b867ad7b6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3946,6 +3946,12 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria )) if obj.unified_job_template: res['unified_job_template'] = obj.unified_job_template.get_absolute_url(self.context.get('request')) + if obj.unified_job_template.project: + res['project'] = obj.unified_job_template.project.get_absolute_url(self.context.get('request')) + if obj.inventory: + res['inventory'] = obj.inventory.get_absolute_url(self.context.get('request')) + elif obj.unified_job_template and obj.unified_job_template.inventory: + res['inventory'] = obj.unified_job_template.inventory.get_absolute_url(self.context.get('request')) return res def validate_unified_job_template(self, value): From db0b2e6cb671edf19d074a843c82749d3f0acf53 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 1 Feb 2018 10:44:08 -0500 Subject: [PATCH 14/82] Tweaked smart status icon styling to prevent overlap with action buttons --- .../src/smart-status/smart-status.block.less | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/awx/ui/client/src/smart-status/smart-status.block.less b/awx/ui/client/src/smart-status/smart-status.block.less index 96ce93228f..01f9cfee65 100644 --- a/awx/ui/client/src/smart-status/smart-status.block.less +++ b/awx/ui/client/src/smart-status/smart-status.block.less @@ -11,14 +11,14 @@ } .SmartStatus-icon { - width: 16px; - height: 16px; + width: 14px; + height: 14px; } .SmartStatus-iconDirectionPlaceholder { - width: 16px; - height: 8px; + width: 14px; + height: 7px; border: 1px solid #d7d7d7; background: #f2f2f2; } @@ -32,8 +32,8 @@ } .SmartStatus-iconIndicator { - width: 16px; - height: 8px; + width: 14px; + height: 7px; } .SmartStatus-iconIndicator--success { @@ -45,8 +45,8 @@ } .SmartStatus-iconPlaceholder { - height: 15px; - width: 15px; + height: 14px; + width: 14px; border: 1px solid #d7d7d7; background: #f2f2f2; } From 290a296f9faecb3d9186aed862380c46588864d8 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Thu, 1 Feb 2018 09:52:53 -0500 Subject: [PATCH 15/82] add xss test for jobs schedules * Test for tooltip regression on job schedules list entries --- awx/ui/test/e2e/fixtures.js | 2 +- awx/ui/test/e2e/objects/jobs.js | 21 +++++++++++++++++++++ awx/ui/test/e2e/tests/test-xss.js | 24 ++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 awx/ui/test/e2e/objects/jobs.js diff --git a/awx/ui/test/e2e/fixtures.js b/awx/ui/test/e2e/fixtures.js index a0f886e074..a0478ddac5 100644 --- a/awx/ui/test/e2e/fixtures.js +++ b/awx/ui/test/e2e/fixtures.js @@ -282,7 +282,7 @@ const getJobTemplateSchedule = (namespace = session) => getJobTemplate(namespace .then(template => getOrCreate(template.related.schedules, { name: `${template.name}-schedule`, description: namespace, - rrule: 'DTSTART:20171104T040000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1' + rrule: 'DTSTART:20351104T040000Z RRULE:FREQ=DAILY;INTERVAL=1;COUNT=1' })); module.exports = { diff --git a/awx/ui/test/e2e/objects/jobs.js b/awx/ui/test/e2e/objects/jobs.js new file mode 100644 index 0000000000..81bd3d432c --- /dev/null +++ b/awx/ui/test/e2e/objects/jobs.js @@ -0,0 +1,21 @@ +import _ from 'lodash'; + +import actions from './sections/actions'; +import breadcrumb from './sections/breadcrumb'; +import createFormSection from './sections/createFormSection'; +import createTableSection from './sections/createTableSection'; +import header from './sections/header'; +import lookupModal from './sections/lookupModal'; +import navigation from './sections/navigation'; +import pagination from './sections/pagination'; +import permissions from './sections/permissions'; +import search from './sections/search'; + +module.exports = { + url () { + return `${this.api.globals.launch_url}/#/jobs`; + }, + sections: {}, // TODO: Fill this out + elements: {}, // TODO: Fill this out + commands: [], // TODO: Fill this out as needed +}; diff --git a/awx/ui/test/e2e/tests/test-xss.js b/awx/ui/test/e2e/tests/test-xss.js index 7888824691..b5c80afe31 100644 --- a/awx/ui/test/e2e/tests/test-xss.js +++ b/awx/ui/test/e2e/tests/test-xss.js @@ -12,6 +12,7 @@ import { getSmartInventory, getTeam, getUpdatedProject, + getJobs, } from '../fixtures'; const data = {}; @@ -49,6 +50,7 @@ module.exports = { pages.teams = client.page.teams(); pages.users = client.page.users(); pages.notificationTemplates = client.page.notificationTemplates(); + pages.jobs = client.page.jobs(); urls.organization = `${pages.organizations.url()}/${data.organization.id}`; urls.inventory = `${pages.inventories.url()}/inventory/${data.inventory.id}`; @@ -63,6 +65,8 @@ module.exports = { urls.team = `${pages.teams.url()}/${data.team.id}`; urls.user = `${pages.users.url()}/${data.user.id}`; urls.notification = `${pages.notificationTemplates.url()}/${data.notification.id}`; + urls.jobs = `${pages.jobs.url()}`; + urls.jobsSchedules = `${pages.jobs.url()}/schedules`; client.useCss(); client.login(); @@ -655,6 +659,26 @@ module.exports = { client.navigateTo(urls.jobTemplateSchedule); client.expect.element('#xss').not.present; client.expect.element('[class=xss]').not.present; + }, + 'check job schedules view for unsanitized content': client => { + const itemRow = `#schedules_table tr[id="${data.jobTemplateSchedule.id}"]`; + const itemName = `${itemRow} td[class*="name-"] a`; + + client.navigateTo(urls.jobsSchedules); + + client.moveToElement(itemName, 0, 0, () => { + client.expect.element(itemName).attribute('aria-describedby'); + client.getAttribute(itemName, 'aria-describedby', ({ value }) => { + const tooltip = `#${value}`; + client.expect.element(tooltip).present; + client.expect.element(tooltip).visible; + + client.expect.element('#xss').not.present; + client.expect.element('[class=xss]').not.present; + client.expect.element(tooltip).attribute('innerHTML') + .contains('<div id="xss" class="xss">test</div>'); + }); + }); client.end(); }, }; From 83aa7bfac47ff07473dd9e427aa024568604ae61 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 1 Feb 2018 11:04:16 -0500 Subject: [PATCH 16/82] Fixed inventory links in activity stream --- .../src/activity-stream/factories/build-anchor.factory.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 914b22c53a..8fe870ce81 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 @@ -20,10 +20,10 @@ export default function BuildAnchor($log, $filter) { if (activity.operation === 'create' || activity.operation === 'delete'){ // the API formats the changes.inventory field as str 'myInventoryName-PrimaryKey' var inventory_id = _.last(activity.changes.inventory.split('-')); - url += 'inventories/' + inventory_id + '/groups/edit/' + activity.changes.id; + url += 'inventories/inventory/' + inventory_id + '/groups/edit/' + activity.changes.id; } else { - url += 'inventories/' + activity.summary_fields.inventory[0].id + '/groups/edit/' + (activity.changes.id || activity.changes.object1_pk); + url += 'inventories/inventory/' + activity.summary_fields.inventory[0].id + '/groups/edit/' + (activity.changes.id || activity.changes.object1_pk); } break; case 'host': @@ -33,7 +33,7 @@ export default function BuildAnchor($log, $filter) { url += 'jobs/' + obj.id; break; case 'inventory': - url += 'inventories/' + 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 From a47b403f8daa3bd9489df91bf519d2d1855c438c Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Thu, 1 Feb 2018 11:05:08 -0500 Subject: [PATCH 17/82] Update saml.md --- docs/auth/saml.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/auth/saml.md b/docs/auth/saml.md index 58e765ce20..ed70cc16aa 100644 --- a/docs/auth/saml.md +++ b/docs/auth/saml.md @@ -80,6 +80,8 @@ Below is another example of a SAML attribute that contains a Team membership in } ``` **saml_attr:** The saml attribute name where the team array can be found. + **remove:** True to remove user from all Teams before adding the user to the list of Teams. False to keep the user in whatever Team(s) they are in while adding the user to the Team(s) in the SAML attribute. + **team_org_map:** An array of dictionaries of the form `{ "team": "", "organization": "" }` that defines mapping from AWX Team -> AWX Organization. This is needed because the same named Team can exist in multiple Organizations in Tower. The organization to which a team listed in a SAML attribute belongs to would be ambiguous without this mapping. From 9170c557a716df8bffedbd896c50d7aeddf238f7 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 1 Feb 2018 11:23:51 -0500 Subject: [PATCH 18/82] Fixed team links in users permissions tab --- awx/ui/client/src/users/edit/users-edit.controller.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/awx/ui/client/src/users/edit/users-edit.controller.js b/awx/ui/client/src/users/edit/users-edit.controller.js index a8639bc1f5..289f2d5556 100644 --- a/awx/ui/client/src/users/edit/users-edit.controller.js +++ b/awx/ui/client/src/users/edit/users-edit.controller.js @@ -132,6 +132,9 @@ export default ['$scope', '$rootScope', '$stateParams', 'UserForm', 'Rest', case 'organization': $state.go('organizations.edit', { "organization_id": id }, { reload: true }); break; + case 'team': + $state.go('teams.edit', { "team_id": id }, { reload: true }); + break; case 'credential': $state.go('credentials.edit', { "credential_id": id }, { reload: true }); break; From f24289b2bab9cdbcc3e3b6fff2f3e0a0e88ec3fc Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Thu, 1 Feb 2018 11:27:01 -0500 Subject: [PATCH 19/82] Extend saml docs to include new fields added --- docs/auth/saml.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/auth/saml.md b/docs/auth/saml.md index 58e765ce20..33eb32b614 100644 --- a/docs/auth/saml.md +++ b/docs/auth/saml.md @@ -2,7 +2,11 @@ Security Assertion Markup Language, or SAML, is an open standard for exchanging authentication and/or authorization data between an identity provider (i.e. LDAP) and a service provider (i.e. AWX). More concretely, AWX can be configured to talk with SAML in order to authenticate (create/login/logout) users of AWX. User Team and Organization membership can be embedded in the SAML response to AWX. # Configure SAML Authentication -Please see the Tower documentation as well as Ansible blog posts for basic SAML configuration. +Please see the Tower documentation as well as Ansible blog posts for basic SAML configuration. Note that AWX's SAML implementation relies on python-social-auth which uses python-saml. AWX exposes 3 fields that are directly passed to the lower libraries: +* `SOCIAL_AUTH_SAML_SP_EXTRA` is passed to the `python-saml` library configuration's `sp` setting. +* `SOCIAL_AUTH_SAML_SECURITY_CONFIG` is passed to the `python-saml` library configuration's `security` setting. +* `SOCIAL_AUTH_SAML_EXTRA_DATA` +See http://python-social-auth-docs.readthedocs.io/en/latest/backends/saml.html#advanced-settings for more information. # Configure SAML for Team and Organization Membership AWX can be configured to look for particular attributes that contain AWX Team and Organization membership to associate with users when they login to AWX. The attribute names are defined in AWX settings. Specifically, the authentication settings tab and SAML sub category fields *SAML Team Map* and *SAML Organization Attribute Mapping*. The meaning and usefulness of these settings is best motivated through example. @@ -82,4 +86,3 @@ Below is another example of a SAML attribute that contains a Team membership in **saml_attr:** The saml attribute name where the team array can be found. **remove:** True to remove user from all Teams before adding the user to the list of Teams. False to keep the user in whatever Team(s) they are in while adding the user to the Team(s) in the SAML attribute. **team_org_map:** An array of dictionaries of the form `{ "team": "", "organization": "" }` that defines mapping from AWX Team -> AWX Organization. This is needed because the same named Team can exist in multiple Organizations in Tower. The organization to which a team listed in a SAML attribute belongs to would be ambiguous without this mapping. - From 8c261892ee4b2a9abc4a8601fcd2cdc3de6e9b2c Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 1 Feb 2018 13:54:33 -0500 Subject: [PATCH 20/82] Updated empty list text --- awx/ui/client/lib/components/components.strings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 93f5ab1416..0a8714835f 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -90,7 +90,7 @@ function ComponentsStrings (BaseString) { }; ns.list = { - DEFAULT_EMPTY_LIST: t.s('List is empty.') + DEFAULT_EMPTY_LIST: t.s('Please add items to this list.') }; } From 5a1ae9b816184ebf83dcf02a5a7cc6a72790f3b9 Mon Sep 17 00:00:00 2001 From: Jake McDermott Date: Thu, 1 Feb 2018 13:57:07 -0500 Subject: [PATCH 21/82] Update ldap.md --- docs/auth/ldap.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/auth/ldap.md b/docs/auth/ldap.md index b9a172c3d0..107ee8c9ef 100644 --- a/docs/auth/ldap.md +++ b/docs/auth/ldap.md @@ -1,6 +1,12 @@ # LDAP The Lightweight Directory Access Protocol (LDAP) is an open, vendor-neutral, industry standard application protocol for accessing and maintaining distributed directory information services over an Internet Protocol (IP) network. Directory services play an important role in developing intranet and Internet applications by allowing the sharing of information about users, systems, networks, services, and applications throughout the network. +# Configure LDAP Authentication +Please see the Tower documentation as well as Ansible blog posts for basic LDAP configuration. + +LDAP Authentication provides duplicate sets of configuration fields for authentication with up to six different LDAP servers. +The default set of configuration fields take the form `AUTH_LDAP_`. Configuration fields for additional ldap servers are numbered `AUTH_LDAP__`. + ## Test environment setup Please see README.md of this repository: https://github.com/jangsutsr/deploy_ldap.git. From 81bdbef785e184e47da245e082b23ca6122febea Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 1 Feb 2018 14:30:56 -0500 Subject: [PATCH 22/82] fix a bug which can break the schedules list endpoint see: https://github.com/ansible/ansible-tower/issues/7881 related: https://github.com/ansible/awx/pull/1095 --- awx/api/serializers.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 0b867ad7b6..a0822cea0f 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3946,11 +3946,14 @@ class ScheduleSerializer(LaunchConfigurationBaseSerializer, SchedulePreviewSeria )) if obj.unified_job_template: res['unified_job_template'] = obj.unified_job_template.get_absolute_url(self.context.get('request')) - if obj.unified_job_template.project: - res['project'] = obj.unified_job_template.project.get_absolute_url(self.context.get('request')) + try: + if obj.unified_job_template.project: + res['project'] = obj.unified_job_template.project.get_absolute_url(self.context.get('request')) + except ObjectDoesNotExist: + pass if obj.inventory: res['inventory'] = obj.inventory.get_absolute_url(self.context.get('request')) - elif obj.unified_job_template and obj.unified_job_template.inventory: + elif obj.unified_job_template and getattr(obj.unified_job_template, 'inventory', None): res['inventory'] = obj.unified_job_template.inventory.get_absolute_url(self.context.get('request')) return res From 0a8df7fde2b81e10b64bd419c947a8b49f8bfde2 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Thu, 1 Feb 2018 15:37:38 -0500 Subject: [PATCH 23/82] work around a bug in dateutil that incorrectly parses Z dates related: https://github.com/dateutil/dateutil/issues/349 --- awx/main/models/schedules.py | 2 +- awx/main/tests/functional/models/test_schedule.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/awx/main/models/schedules.py b/awx/main/models/schedules.py index 59629c89a7..de4c164e65 100644 --- a/awx/main/models/schedules.py +++ b/awx/main/models/schedules.py @@ -127,7 +127,7 @@ class Schedule(CommonModel, LaunchTimeConfig): https://github.com/dateutil/dateutil/pull/619 """ kwargs['forceset'] = True - kwargs['tzinfos'] = {} + kwargs['tzinfos'] = {x: dateutil.tz.tzutc() for x in dateutil.parser.parserinfo().UTCZONE} match = cls.TZID_REGEX.match(rrule) if match is not None: rrule = cls.TZID_REGEX.sub("DTSTART\gTZI\g", rrule) diff --git a/awx/main/tests/functional/models/test_schedule.py b/awx/main/tests/functional/models/test_schedule.py index 3768058f4f..ff37667371 100644 --- a/awx/main/tests/functional/models/test_schedule.py +++ b/awx/main/tests/functional/models/test_schedule.py @@ -146,15 +146,16 @@ def test_tzinfo_naive_until(job_template, dtstart, until): @pytest.mark.django_db -def test_mismatched_until_timezone(job_template): - rrule = 'DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T000000' + 'Z' # noqa the Z isn't allowed, because we have a TZID=America/New_York +def test_until_must_be_utc(job_template): + rrule = 'DTSTART;TZID=America/New_York:20180601T120000 RRULE:FREQ=DAILY;INTERVAL=1;UNTIL=20180602T000000' # noqa the Z is required s = Schedule( name='Some Schedule', rrule=rrule, unified_job_template=job_template ) - with pytest.raises(ValueError): + with pytest.raises(ValueError) as e: s.save() + assert 'RRULE UNTIL values must be specified in UTC' in str(e) @pytest.mark.django_db From c9ff3e99b8f84f16e4763b965a82365b234321db Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 26 Sep 2017 10:28:41 -0400 Subject: [PATCH 24/82] celeryd attach to queues dynamically * Based on the tower topology (Instance and InstanceGroup relationships), have celery dyamically listen to queues on boot * Add celery task capable of "refreshing" what queues each celeryd worker listens to. This will be used to support changes in the topology. * Cleaned up some celery task definitions. * Converged wrongly targeted job launch/finish messages to 'tower' queue, rather than a 1-off queue. * Dynamically route celery tasks destined for the local node * separate beat process add support for separate beat process --- Makefile | 2 +- awx/main/models/ha.py | 3 + awx/main/scheduler/tasks.py | 4 +- awx/main/tasks.py | 34 +++++- awx/main/tests/unit/utils/test_ha.py | 100 ++++++++++++++++++ awx/main/utils/ha.py | 71 +++++++++++++ awx/settings/defaults.py | 48 +++++---- awx/settings/development.py | 9 -- installer/image_build/files/settings.py | 3 - .../image_build/files/supervisor_task.conf | 3 +- tools/docker-compose/supervisor.conf | 2 +- 11 files changed, 237 insertions(+), 42 deletions(-) create mode 100644 awx/main/tests/unit/utils/test_ha.py create mode 100644 awx/main/utils/ha.py diff --git a/Makefile b/Makefile index 58479e0f19..874a4e52ee 100644 --- a/Makefile +++ b/Makefile @@ -326,7 +326,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) -Q tower_scheduler,tower_broadcast_all,$(COMPOSE_HOST),$(AWX_GROUP_QUEUES) -n celery@$(COMPOSE_HOST) --pidfile /tmp/celery_pid + celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=$(CELERY_SCHEDULE_FILE) -Q tower_broadcast_all -n celery@$(COMPOSE_HOST) --pidfile /tmp/celery_pid # Run to start the zeromq callback receiver receiver: diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index f2e57f7a07..cb63beb126 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -63,6 +63,9 @@ class Instance(models.Model): grace_period = settings.AWX_ISOLATED_PERIODIC_CHECK * 2 return self.modified < ref_time - timedelta(seconds=grace_period) + def is_controller(self): + return Instance.objects.filter(rampart_groups__controller__instances=self).exists() + class InstanceGroup(models.Model): """A model representing a Queue/Group of AWX Instances.""" diff --git a/awx/main/scheduler/tasks.py b/awx/main/scheduler/tasks.py index 70d4c95354..89e36f6a93 100644 --- a/awx/main/scheduler/tasks.py +++ b/awx/main/scheduler/tasks.py @@ -21,12 +21,12 @@ class LogErrorsTask(Task): super(LogErrorsTask, self).on_failure(exc, task_id, args, kwargs, einfo) -@shared_task +@shared_task(base=LogErrorsTask) def run_job_launch(job_id): TaskManager().schedule() -@shared_task +@shared_task(base=LogErrorsTask) def run_job_complete(job_id): TaskManager().schedule() diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 1f74fb04a1..36274b926d 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -26,7 +26,7 @@ except Exception: # Celery from celery import Task, shared_task -from celery.signals import celeryd_init, worker_process_init, worker_shutdown +from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, beat_init # Django from django.conf import settings @@ -57,6 +57,7 @@ from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, ignore_inventory_computed_fields, ignore_inventory_group_removal, get_type_for_model, extract_ansible_vars) from awx.main.utils.reload import restart_local_services, stop_local_services +from awx.main.utils.ha import update_celery_worker_routes, register_celery_worker_queues from awx.main.utils.handlers import configure_external_logger from awx.main.consumers import emit_channel_notification from awx.conf import settings_registry @@ -147,6 +148,37 @@ def handle_setting_changes(self, setting_keys): break +@shared_task(bind=True, queue='tower_broadcast_all', base=LogErrorsTask) +def handle_ha_toplogy_changes(self): + instance = Instance.objects.me() + logger.debug("Reconfigure celeryd queues task on host {}".format(self.request.hostname)) + (instance, removed_queues, added_queues) = register_celery_worker_queues(self.app, self.request.hostname) + logger.info("Workers on tower node '{}' removed from queues {} and added to queues {}" + .format(instance.hostname, removed_queues, added_queues)) + updated_routes = update_celery_worker_routes(instance, settings) + logger.info("Worker on tower node '{}' updated celery routes {} all routes are now {}" + .format(instance.hostname, updated_routes, self.app.conf.CELERY_ROUTES)) + + +@worker_ready.connect +def handle_ha_toplogy_worker_ready(sender, **kwargs): + logger.debug("Configure celeryd queues task on host {}".format(sender.hostname)) + (instance, removed_queues, added_queues) = register_celery_worker_queues(sender.app, sender.hostname) + logger.info("Workers on tower node '{}' unsubscribed from queues {} and subscribed to queues {}" + .format(instance.hostname, removed_queues, added_queues)) + + +@beat_init.connect +@celeryd_init.connect +def handle_update_celery_routes(sender=None, conf=None, **kwargs): + conf = conf if conf else sender.app.conf + logger.debug("Registering celery routes for {}".format(sender)) + instance = Instance.objects.me() + added_routes = update_celery_worker_routes(instance, conf) + logger.info("Workers on tower node '{}' added routes {} all routes are now {}" + .format(instance.hostname, added_routes, conf.CELERY_ROUTES)) + + @shared_task(queue='tower', base=LogErrorsTask) def send_notifications(notification_list, job_id=None): if not isinstance(notification_list, list): diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py new file mode 100644 index 0000000000..6bd1b856b9 --- /dev/null +++ b/awx/main/tests/unit/utils/test_ha.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Ansible Tower by Red Hat +# All Rights Reserved. + +# python +import pytest +import mock + +# AWX +from awx.main.utils.ha import ( + _add_remove_celery_worker_queues, + update_celery_worker_routes, +) + + +@pytest.fixture +def conf(): + class Conf(): + CELERY_ROUTES = dict() + CELERYBEAT_SCHEDULE = dict() + return Conf() + + +class TestAddRemoveCeleryWorkerQueues(): + @pytest.fixture + def instance_generator(self, mocker): + def fn(groups=['east', 'west', 'north', 'south'], hostname='east-1'): + instance = mocker.MagicMock() + instance.hostname = hostname + instance.rampart_groups = mocker.MagicMock() + instance.rampart_groups.values_list = mocker.MagicMock(return_value=groups) + + return instance + return fn + + @pytest.fixture + def worker_queues_generator(self, mocker): + def fn(queues=['east', 'west']): + return [dict(name=n, alias='') for n in queues] + return fn + + @pytest.fixture + def mock_app(self, mocker): + app = mocker.MagicMock() + app.control = mocker.MagicMock() + app.control.cancel_consumer = mocker.MagicMock() + return app + + @pytest.mark.parametrize("static_queues,_worker_queues,groups,hostname,added_expected,removed_expected", [ + (['east', 'west'], ['east', 'west', 'east-1'], [], 'east-1', [], []), + ([], ['east', 'west', 'east-1'], ['east', 'west'], 'east-1', [], []), + ([], ['east', 'west'], ['east', 'west'], 'east-1', ['east-1'], []), + ([], [], ['east', 'west'], 'east-1', ['east', 'west', 'east-1'], []), + ([], ['china', 'russia'], ['east', 'west'], 'east-1', ['east', 'west', 'east-1'], ['china', 'russia']), + ]) + def test__add_remove_celery_worker_queues_noop(self, mock_app, + instance_generator, + worker_queues_generator, + static_queues, _worker_queues, + groups, hostname, + added_expected, removed_expected): + 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): + (added_queues, removed_queues) = _add_remove_celery_worker_queues(mock_app, instance, worker_queues, hostname) + assert set(added_queues) == set(added_expected) + assert set(removed_queues) == set(removed_expected) + + +class TestUpdateCeleryWorkerRoutes(): + + @pytest.mark.parametrize("is_controller,expected_routes", [ + (False, { + 'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'}, + 'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'} + }), + (True, { + 'awx.main.tasks.cluster_node_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'}, + 'awx.main.tasks.purge_old_stdout_files': {'queue': 'east-1', 'routing_key': 'east-1'}, + 'awx.main.tasks.awx_isolated_heartbeat': {'queue': 'east-1', 'routing_key': 'east-1'}, + }), + ]) + def test_update_celery_worker_routes(self, mocker, conf, is_controller, expected_routes): + instance = mocker.MagicMock() + instance.hostname = 'east-1' + instance.is_controller = mocker.MagicMock(return_value=is_controller) + + assert update_celery_worker_routes(instance, conf) == expected_routes + assert conf.CELERY_ROUTES == expected_routes + + def test_update_celery_worker_routes_deleted(self, mocker, conf): + instance = mocker.MagicMock() + instance.hostname = 'east-1' + instance.is_controller = mocker.MagicMock(return_value=False) + conf.CELERY_ROUTES = {'awx.main.tasks.awx_isolated_heartbeat': 'foobar'} + + update_celery_worker_routes(instance, conf) + assert 'awx.main.tasks.awx_isolated_heartbeat' not in conf.CELERY_ROUTES + diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py new file mode 100644 index 0000000000..9efb3e9cf3 --- /dev/null +++ b/awx/main/utils/ha.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 Ansible Tower by Red Hat +# All Rights Reserved. + +# Django +from django.conf import settings + +# AWX +from awx.main.models import Instance + + +def _add_remove_celery_worker_queues(app, instance, worker_queues, worker_name): + removed_queues = [] + added_queues = [] + ig_names = set(instance.rampart_groups.values_list('name', flat=True)) + worker_queue_names = set([q['name'] for q in worker_queues]) + + + # 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: + continue + + if queue['name'] not in ig_names | set([instance.hostname]): + app.control.cancel_consumer(queue['name'], reply=True, destination=[worker_name]) + removed_queues.append(queue['name']) + + # Add queues for instance and instance groups + for queue_name in ig_names | set([instance.hostname]): + if queue_name not in worker_queue_names: + app.control.add_consumer(queue_name, reply=True, destination=[worker_name]) + added_queues.append(queue_name) + + return (added_queues, removed_queues) + + +def update_celery_worker_routes(instance, conf): + tasks = [ + 'awx.main.tasks.cluster_node_heartbeat', + 'awx.main.tasks.purge_old_stdout_files', + ] + routes_updated = {} + + # Instance is, effectively, a controller node + if instance.is_controller(): + tasks.append('awx.main.tasks.awx_isolated_heartbeat') + else: + if 'awx.main.tasks.awx_isolated_heartbeat' in conf.CELERY_ROUTES: + del conf.CELERY_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] + + for t in tasks: + conf.CELERY_ROUTES[t] = {'queue': instance.hostname, 'routing_key': instance.hostname} + routes_updated[t] = conf.CELERY_ROUTES[t] + + return routes_updated + + +def register_celery_worker_queues(app, celery_worker_name): + instance = Instance.objects.me() + added_queues = [] + removed_queues = [] + + celery_host_queues = app.control.inspect([celery_worker_name]).active_queues() + + celery_worker_queues = celery_host_queues[celery_worker_name] if celery_host_queues else [] + (added_queues, removed_queues) = _add_remove_celery_worker_queues(app, instance, celery_worker_queues, celery_worker_name) + + return (instance, removed_queues, added_queues) + diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 2bcb8ee3d2..c97348e45e 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -392,6 +392,18 @@ EMAIL_HOST_USER = '' EMAIL_HOST_PASSWORD = '' EMAIL_USE_TLS = False +# The number of seconds to sleep between status checks for jobs running on isolated nodes +AWX_ISOLATED_CHECK_INTERVAL = 30 + +# The timeout (in seconds) for launching jobs on isolated nodes +AWX_ISOLATED_LAUNCH_TIMEOUT = 600 + +# Ansible connection timeout (in seconds) for communicating with isolated instances +AWX_ISOLATED_CONNECTION_TIMEOUT = 10 + +# The time (in seconds) between the periodic isolated heartbeat status check +AWX_ISOLATED_PERIODIC_CHECK = 600 + # Memcached django cache configuration # CACHES = { # 'default': { @@ -435,20 +447,12 @@ CELERY_BEAT_MAX_LOOP_INTERVAL = 60 CELERY_RESULT_BACKEND = 'django-db' CELERY_IMPORTS = ('awx.main.scheduler.tasks',) CELERY_TASK_QUEUES = ( - Queue('default', Exchange('default'), routing_key='default'), Queue('tower', Exchange('tower'), routing_key='tower'), - Queue('tower_scheduler', Exchange('scheduler', type='topic'), routing_key='tower_scheduler.job.#', durable=False), Broadcast('tower_broadcast_all') ) -CELERY_TASK_ROUTES = { - 'awx.main.scheduler.tasks.run_task_manager': {'queue': 'tower', 'routing_key': 'tower'}, - 'awx.main.scheduler.tasks.run_job_launch': {'queue': 'tower_scheduler', 'routing_key': 'tower_scheduler.job.launch'}, - 'awx.main.scheduler.tasks.run_job_complete': {'queue': 'tower_scheduler', 'routing_key': 'tower_scheduler.job.complete'}, - 'awx.main.tasks.cluster_node_heartbeat': {'queue': 'default', 'routing_key': 'cluster.heartbeat'}, - 'awx.main.tasks.purge_old_stdout_files': {'queue': 'default', 'routing_key': 'cluster.heartbeat'}, -} +CELERY_TASK_ROUTES = {} -CELERY_BEAT_SCHEDULE = { +CELERYBEAT_SCHEDULE = { 'tower_scheduler': { 'task': 'awx.main.tasks.awx_periodic_scheduler', 'schedule': timedelta(seconds=30), @@ -474,11 +478,21 @@ CELERY_BEAT_SCHEDULE = { 'task_manager': { 'task': 'awx.main.scheduler.tasks.run_task_manager', 'schedule': timedelta(seconds=20), - 'options': {'expires': 20,} + 'options': {'expires': 20} }, + 'isolated_heartbeat': { + 'task': 'awx.main.tasks.awx_isolated_heartbeat', + 'schedule': timedelta(seconds=AWX_ISOLATED_PERIODIC_CHECK), + 'options': {'expires': AWX_ISOLATED_PERIODIC_CHECK * 2}, + } } 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',] + # Django Caching Configuration if is_testing(): CACHES = { @@ -627,18 +641,6 @@ AWX_ANSIBLE_CALLBACK_PLUGINS = "" # Time at which an HA node is considered active AWX_ACTIVE_NODE_TIME = 7200 -# The number of seconds to sleep between status checks for jobs running on isolated nodes -AWX_ISOLATED_CHECK_INTERVAL = 30 - -# The timeout (in seconds) for launching jobs on isolated nodes -AWX_ISOLATED_LAUNCH_TIMEOUT = 600 - -# Ansible connection timeout (in seconds) for communicating with isolated instances -AWX_ISOLATED_CONNECTION_TIMEOUT = 10 - -# The time (in seconds) between the periodic isolated heartbeat status check -AWX_ISOLATED_PERIODIC_CHECK = 600 - # Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed' # Note: This setting may be overridden by database settings. PENDO_TRACKING_STATE = "off" diff --git a/awx/settings/development.py b/awx/settings/development.py index 682cf21dd8..617c0b6745 100644 --- a/awx/settings/development.py +++ b/awx/settings/development.py @@ -138,15 +138,6 @@ except ImportError: sys.exit(1) CLUSTER_HOST_ID = socket.gethostname() -CELERY_TASK_ROUTES['awx.main.tasks.cluster_node_heartbeat'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} -# Production only runs this schedule on controlling nodes -# but development will just run it on all nodes -CELERY_TASK_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} -CELERY_BEAT_SCHEDULE['isolated_heartbeat'] = { - 'task': 'awx.main.tasks.awx_isolated_heartbeat', - 'schedule': timedelta(seconds = AWX_ISOLATED_PERIODIC_CHECK), - 'options': {'expires': AWX_ISOLATED_PERIODIC_CHECK * 2,} -} # Supervisor service name dictionary used for programatic restart SERVICE_NAME_DICT = { diff --git a/installer/image_build/files/settings.py b/installer/image_build/files/settings.py index d9a56df2f1..aac778aaba 100644 --- a/installer/image_build/files/settings.py +++ b/installer/image_build/files/settings.py @@ -31,9 +31,6 @@ AWX_PROOT_ENABLED = False CLUSTER_HOST_ID = "awx" SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' -CELERY_TASK_QUEUES += (Queue(CLUSTER_HOST_ID, Exchange(CLUSTER_HOST_ID), routing_key=CLUSTER_HOST_ID),) -CELERY_TASK_ROUTES['awx.main.tasks.cluster_node_heartbeat'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} -CELERY_TASK_ROUTES['awx.main.tasks.purge_old_stdout_files'] = {'queue': CLUSTER_HOST_ID, 'routing_key': CLUSTER_HOST_ID} ############################################################################### diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 857f941c96..ad49df4587 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,8 +3,7 @@ nodaemon = True umask = 022 [program:celery] -# TODO: Needs to be reworked to dynamically use instance group queues -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_scheduler,tower_broadcast_all,tower,%(host_node_name)s -n celery@localhost +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@localhost directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} diff --git a/tools/docker-compose/supervisor.conf b/tools/docker-compose/supervisor.conf index 7f72e269c4..b0700e1442 100644 --- a/tools/docker-compose/supervisor.conf +++ b/tools/docker-compose/supervisor.conf @@ -4,7 +4,7 @@ minfds = 4096 nodaemon=true [program:celeryd] -command = celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=/celerybeat-schedule -Q tower_scheduler,tower_broadcast_all,%(ENV_AWX_GROUP_QUEUES)s,%(ENV_HOSTNAME)s -n celery@%(ENV_HOSTNAME)s +command = celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=/celerybeat-schedule -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s autostart = true autorestart = true redirect_stderr=true From 6ede1dfbea42b6ceda61f2aa07d34c8a8866ff51 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 5 Oct 2017 20:52:17 -0400 Subject: [PATCH 25/82] Update openshift installer to support rabbitmq autoscale * Switch rabbitmq container out for one that supports autoscale * Add etcd pod to support autoscale negotiation --- installer/openshift/tasks/main.yml | 9 ++++ .../openshift/templates/deployment.yml.j2 | 28 ++++++++++-- installer/openshift/templates/etcd.yml.j2 | 44 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 installer/openshift/templates/etcd.yml.j2 diff --git a/installer/openshift/tasks/main.yml b/installer/openshift/tasks/main.yml index 0de60caa98..fd6c967d24 100644 --- a/installer/openshift/tasks/main.yml +++ b/installer/openshift/tasks/main.yml @@ -121,6 +121,15 @@ dest: "{{ openshift_base_path }}/deployment.yml" mode: '0600' +- name: Template Openshift AWX etcd2 + template: + src: etcd.yml.j2 + dest: "{{ openshift_base_path }}/etcd.yml" + mode: '0600' + +- name: Apply etcd deployment + shell: "oc apply -f {{ openshift_base_path }}/etcd.yml" + - name: Apply Configmap shell: "oc apply -f {{ openshift_base_path }}/configmap.yml" diff --git a/installer/openshift/templates/deployment.yml.j2 b/installer/openshift/templates/deployment.yml.j2 index 775ad8a49c..1152503b12 100644 --- a/installer/openshift/templates/deployment.yml.j2 +++ b/installer/openshift/templates/deployment.yml.j2 @@ -41,18 +41,40 @@ spec: - name: AWX_ADMIN_PASSWORD value: {{ default_admin_password|default('password') }} - name: awx-rabbit - image: rabbitmq:3 + image: ansible/awx_rabbitmq:latest + imagePullPolicy: Always env: + # For consupmption by rabbitmq-env.conf + - name: MY_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: RABBITMQ_USE_LONGNAME + value: "true" + - name: ERLANG_COOKIE + value: "test" - name: RABBITMQ_ERLANG_COOKIE - value: secretb + value: "secretb" - name: RABBITMQ_NODENAME - value: rabbitmq + value: "rabbit@$(MY_POD_IP)" + - name: AUTOCLUSTER_TYPE + value: "etcd" + - name: AUTOCLUSTER_DELAY + value: "60" + - name: ETCD_HOST + value: "etcd" + - name: AUTOCLUSTER_CLEANUP + value: "true" + - name: CLEANUP_WARN_ONLY + value: "true" - name: RABBITMQ_DEFAULT_USER value: awx - name: RABBITMQ_DEFAULT_PASS value: abcdefg - name: RABBITMQ_DEFAULT_VHOST value: awx + - name: RABBITMQ_CONFIG_FILE + value: /etc/rabbitmq/rabbitmq - name: awx-memcached image: memcached volumes: diff --git a/installer/openshift/templates/etcd.yml.j2 b/installer/openshift/templates/etcd.yml.j2 new file mode 100644 index 0000000000..abbfe13185 --- /dev/null +++ b/installer/openshift/templates/etcd.yml.j2 @@ -0,0 +1,44 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: etcd + namespace: {{ awx_openshift_project }} +spec: + replicas: 1 + template: + metadata: + labels: + name: awx-etcd2 + service: etcd + spec: + containers: + - name: etcd + image: elcolio/etcd:latest + ports: + - containerPort: 4001 + volumeMounts: + - mountPath: /data + name: datadir + volumes: + - name: datadir + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + labels: + name: awx-etcd + name: etcd + namespace: {{ awx_openshift_project }} +spec: + ports: + - name: etcd + port: 4001 + protocol: TCP + targetPort: 4001 + selector: + name: awx-etcd2 + sessionAffinity: None + type: ClusterIP From 624289bed79af33b6a0765e63c6489341bf837e0 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Fri, 6 Oct 2017 10:47:02 -0400 Subject: [PATCH 26/82] Add support for directly managing instance groups * Associating/Disassociating an instance with a group * Triggering a topology rebuild on that change * Force rabbitmq cleanup of offline nodes * Automatically check for dependent service startup * Fetch and set hostname for celery so it doesn't clobber other celeries * Rely on celery init signals to dyanmically set listen queues * Removing old total_capacity instance manager property --- awx/api/views.py | 33 ++++++++++++++++--- awx/main/access.py | 18 ++++++++-- awx/main/managers.py | 5 --- awx/main/tests/functional/test_jobs.py | 5 +-- .../image_build/files/launch_awx_task.sh | 5 +++ .../image_build/files/supervisor_task.conf | 2 +- .../openshift/templates/deployment.yml.j2 | 21 +++++++++++- 7 files changed, 70 insertions(+), 19 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index ac835a30d0..df7d2b61e8 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -57,7 +57,7 @@ import pytz from wsgiref.util import FileWrapper # AWX -from awx.main.tasks import send_notifications +from awx.main.tasks import send_notifications, handle_ha_toplogy_changes from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TokenGetAuthentication @@ -148,6 +148,29 @@ class UnifiedJobDeletionMixin(object): return Response(status=status.HTTP_204_NO_CONTENT) +class InstanceGroupMembershipMixin(object): + ''' + Manages signaling celery to reload its queue configuration on Instance Group membership changes + ''' + def attach(self, request, *args, **kwargs): + response = super(InstanceGroupMembershipMixin, self).attach(request, *args, **kwargs) + if status.is_success(response.status_code): + handle_ha_toplogy_changes.apply_async() + return response + + def unattach(self, request, *args, **kwargs): + response = super(InstanceGroupMembershipMixin, self).unattach(request, *args, **kwargs) + if status.is_success(response.status_code): + handle_ha_toplogy_changes.apply_async() + return response + + def destroy(self, request, *args, **kwargs): + response = super(InstanceGroupMembershipMixin, self).destroy(request, *args, **kwargs) + if status.is_success(response.status_code): + handle_ha_toplogy_changes.apply_async() + return response + + class ApiRootView(APIView): authentication_classes = [] @@ -548,7 +571,7 @@ class InstanceUnifiedJobsList(SubListAPIView): return qs -class InstanceInstanceGroupsList(SubListAPIView): +class InstanceInstanceGroupsList(InstanceGroupMembershipMixin, SubListCreateAttachDetachAPIView): view_name = _("Instance's Instance Groups") model = InstanceGroup @@ -558,7 +581,7 @@ class InstanceInstanceGroupsList(SubListAPIView): relationship = 'rampart_groups' -class InstanceGroupList(ListAPIView): +class InstanceGroupList(ListCreateAPIView): view_name = _("Instance Groups") model = InstanceGroup @@ -566,7 +589,7 @@ class InstanceGroupList(ListAPIView): new_in_320 = True -class InstanceGroupDetail(RetrieveAPIView): +class InstanceGroupDetail(InstanceGroupMembershipMixin, RetrieveDestroyAPIView): view_name = _("Instance Group Detail") model = InstanceGroup @@ -584,7 +607,7 @@ class InstanceGroupUnifiedJobsList(SubListAPIView): new_in_320 = True -class InstanceGroupInstanceList(SubListAPIView): +class InstanceGroupInstanceList(InstanceGroupMembershipMixin, SubListAttachDetachAPIView): view_name = _("Instance Group's Instances") model = Instance diff --git a/awx/main/access.py b/awx/main/access.py index 174396e59b..5ce76a52f8 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -424,6 +424,18 @@ class InstanceAccess(BaseAccess): return Instance.objects.filter( rampart_groups__in=self.user.get_queryset(InstanceGroup)).distinct() + + def can_attach(self, obj, sub_obj, relationship, data, + skip_sub_obj_read_check=False): + if relationship == 'rampart_groups' and isinstance(sub_obj, InstanceGroup): + return self.user.is_superuser + return super(InstanceAccess, self).can_attach(obj, sub_obj, relationship, *args, **kwargs) + + def can_unattach(self, obj, sub_obj, relationship, data=None): + if relationship == 'rampart_groups' and isinstance(sub_obj, InstanceGroup): + return self.user.is_superuser + return super(InstanceAccess, self).can_unattach(obj, sub_obj, relationship, *args, **kwargs) + def can_add(self, data): return False @@ -444,13 +456,13 @@ class InstanceGroupAccess(BaseAccess): organization__in=Organization.accessible_pk_qs(self.user, 'admin_role')) def can_add(self, data): - return False + return self.user.is_superuser def can_change(self, obj, data): - return False + return self.user.is_superuser def can_delete(self, obj): - return False + return self.user.is_superuser class UserAccess(BaseAccess): diff --git a/awx/main/managers.py b/awx/main/managers.py index aa478cb027..f6f2dfd5b7 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -93,11 +93,6 @@ class InstanceManager(models.Manager): """Return count of active Tower nodes for licensing.""" return self.all().count() - def total_capacity(self): - sumval = self.filter(modified__gte=now() - timedelta(seconds=settings.AWX_ACTIVE_NODE_TIME)) \ - .aggregate(total_capacity=Sum('capacity'))['total_capacity'] - return max(50, sumval) - def my_role(self): # NOTE: TODO: Likely to repurpose this once standalone ramparts are a thing return "tower" diff --git a/awx/main/tests/functional/test_jobs.py b/awx/main/tests/functional/test_jobs.py index 6500963fc1..e9504d1232 100644 --- a/awx/main/tests/functional/test_jobs.py +++ b/awx/main/tests/functional/test_jobs.py @@ -1,7 +1,4 @@ -from awx.main.models import ( - Job, - Instance -) +from awx.main.models import Job, Instance from django.test.utils import override_settings import pytest diff --git a/installer/image_build/files/launch_awx_task.sh b/installer/image_build/files/launch_awx_task.sh index b2e489d069..88b59d63fa 100755 --- a/installer/image_build/files/launch_awx_task.sh +++ b/installer/image_build/files/launch_awx_task.sh @@ -4,7 +4,12 @@ if [ `id -u` -ge 500 ]; then cat /tmp/passwd > /etc/passwd rm /tmp/passwd fi + +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=$DATABASE_HOST port=$DATABASE_PORT" all +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=localhost port=11211" all +ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m wait_for -a "host=localhost port=5672" all ANSIBLE_REMOTE_TEMP=/tmp ANSIBLE_LOCAL_TEMP=/tmp ansible -i "127.0.0.1," -c local -v -m postgresql_db -U $DATABASE_USER -a "name=$DATABASE_NAME owner=$DATABASE_USER login_user=$DATABASE_USER login_host=$DATABASE_HOST login_password=$DATABASE_PASSWORD port=$DATABASE_PORT" all + awx-manage migrate --noinput --fake-initial if [ ! -z "$AWX_ADMIN_USER" ]&&[ ! -z "$AWX_ADMIN_PASSWORD" ]; then echo "from django.contrib.auth.models import User; User.objects.create_superuser('$AWX_ADMIN_USER', 'root@localhost', '$AWX_ADMIN_PASSWORD')" | awx-manage shell diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index ad49df4587..19aac3c3b0 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,7 +3,7 @@ nodaemon = True umask = 022 [program:celery] -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@localhost +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} diff --git a/installer/openshift/templates/deployment.yml.j2 b/installer/openshift/templates/deployment.yml.j2 index 1152503b12..eddd193938 100644 --- a/installer/openshift/templates/deployment.yml.j2 +++ b/installer/openshift/templates/deployment.yml.j2 @@ -66,7 +66,9 @@ spec: - name: AUTOCLUSTER_CLEANUP value: "true" - name: CLEANUP_WARN_ONLY - value: "true" + value: "false" + - name: CLEANUP_INTERVAL + value: "30" - name: RABBITMQ_DEFAULT_USER value: awx - name: RABBITMQ_DEFAULT_PASS @@ -102,6 +104,23 @@ spec: selector: name: awx-web-deploy --- +--- +apiVersion: v1 +kind: Service +metadata: + name: awx-rmq-mgmt + namespace: {{ awx_openshift_project }} + labels: + name: awx-rmq-mgmt +spec: + type: ClusterIP + ports: + - name: rmqmgmt + port: 15672 + targetPort: 15672 + selector: + name: awx-web-deploy +--- apiVersion: v1 kind: Route metadata: From 0e97dc4b84a6e677b1b44be17c578fce69a8c1a7 Mon Sep 17 00:00:00 2001 From: Chris Meyers Date: Tue, 10 Oct 2017 12:34:49 -0400 Subject: [PATCH 27/82] Beat and celery clustering fixes * use embedded beat rather than standalone * dynamically set celeryd hostname at runtime * add embeded beat flag to celery startup * Embedded beat mode routes will piggyback off of celery worker setup signal --- Makefile | 2 +- awx/main/tasks.py | 10 ++++++++-- installer/image_build/files/supervisor_task.conf | 14 +------------- tools/docker-compose/supervisor.conf | 2 +- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 874a4e52ee..99e4d0c325 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ COMPOSE_HOST ?= $(shell hostname) VENV_BASE ?= /venv SCL_PREFIX ?= -CELERY_SCHEDULE_FILE ?= /celerybeat-schedule +CELERY_SCHEDULE_FILE ?= /var/lib/awx/beat.db DEV_DOCKER_TAG_BASE ?= gcr.io/ansible-tower-engineering # Python packages to install only from source (not from binary wheels) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 36274b926d..0b52f2e93e 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -26,7 +26,7 @@ except Exception: # Celery from celery import Task, shared_task -from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, beat_init +from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, celeryd_after_setup # Django from django.conf import settings @@ -168,7 +168,6 @@ def handle_ha_toplogy_worker_ready(sender, **kwargs): .format(instance.hostname, removed_queues, added_queues)) -@beat_init.connect @celeryd_init.connect def handle_update_celery_routes(sender=None, conf=None, **kwargs): conf = conf if conf else sender.app.conf @@ -179,6 +178,13 @@ def handle_update_celery_routes(sender=None, conf=None, **kwargs): .format(instance.hostname, added_routes, conf.CELERY_ROUTES)) +@celeryd_after_setup.connect +def handle_update_celery_hostname(sender, instance, **kwargs): + tower_instance = Instance.objects.me() + instance.hostname = 'celery@{}'.format(tower_instance.hostname) + logger.warn("Set hostname to {}".format(instance.hostname)) + + @shared_task(queue='tower', base=LogErrorsTask) def send_notifications(notification_list, job_id=None): if not isinstance(notification_list, list): diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 19aac3c3b0..3bc71cf75b 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,7 +3,7 @@ nodaemon = True umask = 022 [program:celery] -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -l debug --autoscale=4 -Ofair -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -B -l debug --autoscale=4 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@$(ENV_HOSTNAME)s directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} @@ -15,18 +15,6 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 -[program:awx-celeryd-beat] -command = /var/lib/awx/venv/awx/bin/celery beat -A awx -l debug --pidfile= -s /var/lib/awx/beat.db -directory = /var/lib/awx -autostart = true -autorestart = true -stopwaitsecs = 5 -redirect_stderr=true -stdout_logfile = /dev/stdout -stdout_logfile_maxbytes = 0 -stderr_logfile = /dev/stderr -stderr_logfile_maxbytes = 0 - [program:callback-receiver] command = awx-manage run_callback_receiver directory = /var/lib/awx diff --git a/tools/docker-compose/supervisor.conf b/tools/docker-compose/supervisor.conf index b0700e1442..cedb784324 100644 --- a/tools/docker-compose/supervisor.conf +++ b/tools/docker-compose/supervisor.conf @@ -4,7 +4,7 @@ minfds = 4096 nodaemon=true [program:celeryd] -command = celery worker -A awx -l DEBUG -B -Ofair --autoscale=100,4 --schedule=/celerybeat-schedule -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s +command = celery worker -A awx -l DEBUG -B --autoscale=20,3 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s autostart = true autorestart = true redirect_stderr=true From c819560d39ed101517eb3d8138c33b8d0d8f8547 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 12 Oct 2017 14:14:30 -0400 Subject: [PATCH 28/82] Add automatic deprovisioning support, only enabled for openshift * Implement a config watcher for service restarts * If the configmap bind point changes then restart all services --- awx/main/tasks.py | 4 ++ awx/settings/defaults.py | 3 + installer/image_build/files/supervisor.conf | 9 +++ .../image_build/files/supervisor_task.conf | 9 +++ installer/image_build/tasks/main.yml | 6 ++ installer/image_build/templates/Dockerfile.j2 | 3 +- .../openshift/templates/configmap.yml.j2 | 5 +- tools/scripts/config-watcher | 58 +++++++++++++++++++ 8 files changed, 95 insertions(+), 2 deletions(-) create mode 100755 tools/scripts/config-watcher diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 0b52f2e93e..0e9c8bcea5 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -292,6 +292,10 @@ def cluster_node_heartbeat(self): other_inst.save(update_fields=['capacity']) logger.error("Host {} last checked in at {}, marked as lost.".format( other_inst.hostname, other_inst.modified)) + if settings.AWX_AUTO_DEPROVISION_INSTANCES: + deprovision_hostname = other_inst.hostname + other_inst.delete() + logger.info("Host {} Automatically Deprovisioned.".format(deprovision_hostname)) except DatabaseError as e: if 'did not affect any rows' in str(e): logger.debug('Another instance has marked {} as lost'.format(other_inst.hostname)) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index c97348e45e..db3dec23fb 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -641,6 +641,9 @@ AWX_ANSIBLE_CALLBACK_PLUGINS = "" # Time at which an HA node is considered active AWX_ACTIVE_NODE_TIME = 7200 +# Automatically remove nodes that have missed their heartbeats after some time +AWX_AUTO_DEPROVISION_INSTANCES = False + # Enable Pendo on the UI, possible values are 'off', 'anonymous', and 'detailed' # Note: This setting may be overridden by database settings. PENDO_TRACKING_STATE = "off" diff --git a/installer/image_build/files/supervisor.conf b/installer/image_build/files/supervisor.conf index ec0acac101..cfcaf5ebe9 100644 --- a/installer/image_build/files/supervisor.conf +++ b/installer/image_build/files/supervisor.conf @@ -41,6 +41,15 @@ priority=5 # TODO: Exit Handler +[eventlistener:awx-config-watcher] +command=/usr/bin/config-watcher +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +events=TICK_60 +priority=0 + [unix_http_server] file=/tmp/supervisor.sock diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 3bc71cf75b..1a4e613925 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -43,6 +43,15 @@ priority=5 # TODO: Exit Handler +[eventlistener:awx-config-watcher] +command=/usr/bin/config-watcher +stderr_logfile=/dev/stdout +stderr_logfile_maxbytes=0 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +events=TICK_60 +priority=0 + [unix_http_server] file=/tmp/supervisor.sock diff --git a/installer/image_build/tasks/main.yml b/installer/image_build/tasks/main.yml index d3dd66207f..f67e3abf7e 100644 --- a/installer/image_build/tasks/main.yml +++ b/installer/image_build/tasks/main.yml @@ -163,6 +163,12 @@ dest: "{{ docker_base_path }}/requirements" delegate_to: localhost +- name: Stage config watcher + copy: + src: ../tools/scripts/config-watcher + dest: "{{ docker_base_path }}/config-watcher" + delegate_to: localhost + - name: Stage Makefile copy: src: ../Makefile diff --git a/installer/image_build/templates/Dockerfile.j2 b/installer/image_build/templates/Dockerfile.j2 index 16c118b1fe..ab3db53490 100644 --- a/installer/image_build/templates/Dockerfile.j2 +++ b/installer/image_build/templates/Dockerfile.j2 @@ -22,6 +22,7 @@ ADD requirements/requirements_ansible.txt \ requirements/requirements_git.txt \ /tmp/requirements/ ADD ansible.repo /etc/yum.repos.d/ansible.repo +ADD config-watcher /usr/bin/config-watcher ADD RPM-GPG-KEY-ansible-release /etc/pki/rpm-gpg/RPM-GPG-KEY-ansible-release # OS Dependencies WORKDIR /tmp @@ -50,7 +51,7 @@ ADD supervisor.conf /supervisor.conf ADD supervisor_task.conf /supervisor_task.conf ADD launch_awx.sh /usr/bin/launch_awx.sh ADD launch_awx_task.sh /usr/bin/launch_awx_task.sh -RUN chmod +rx /usr/bin/launch_awx.sh && chmod +rx /usr/bin/launch_awx_task.sh +RUN chmod +rx /usr/bin/launch_awx.sh && chmod +rx /usr/bin/launch_awx_task.sh && chmod +rx /usr/bin/config-watcher ADD settings.py /etc/tower/settings.py RUN chmod g+w /etc/passwd RUN chmod -R 777 /var/log/nginx && chmod -R 777 /var/lib/nginx diff --git a/installer/openshift/templates/configmap.yml.j2 b/installer/openshift/templates/configmap.yml.j2 index 79c14fefee..8fb1e2b4bf 100644 --- a/installer/openshift/templates/configmap.yml.j2 +++ b/installer/openshift/templates/configmap.yml.j2 @@ -12,7 +12,10 @@ data: # Container environments don't like chroots AWX_PROOT_ENABLED = False - + + # Automatically deprovision pods that go offline + AWX_AUTO_DEPROVISION_INSTANCES = True + #Autoprovisioning should replace this CLUSTER_HOST_ID = socket.gethostname() SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' diff --git a/tools/scripts/config-watcher b/tools/scripts/config-watcher new file mode 100755 index 0000000000..ffa2e56a1f --- /dev/null +++ b/tools/scripts/config-watcher @@ -0,0 +1,58 @@ +#!/usr/bin/env python + +import os +import sys +import hashlib +from supervisor import childutils + + +def hash(f): + s = hashlib.sha1() + with open(f, "rb") as fd: + for chunk in iter(lambda: fd.read(4096), b""): + s.update(chunk) + return s.hexdigest() + + +def last_hash(f): + with open(f, "r") as fd: + return fd.read().strip() + + +def write_hash(f, h): + with open(f, "w") as fd: + fd.write(h) + + +def main(): + while 1: + rpc = childutils.getRPCInterface(os.environ) + headers, payload = childutils.listener.wait(sys.stdin, sys.stdout) + if not headers['eventname'].startswith('TICK'): + childutils.listener.ok(sys.stdout) + continue + try: + current_hash = hash("/etc/tower/settings.py") + except: + sys.stderr.write("Could not open settings.py, skipping config watcher") + childutils.listener.ok(sys.stdout) + continue + try: + if current_hash == last_hash("/var/lib/awx/.configsha"): + childutils.listener.ok(sys.stdout) + continue + else: + sys.stderr.write("Config changed, reloading services") + for proc in rpc.supervisor.getAllProcessInfo(): + group = proc['group'] + name = proc['name'] + program = "{}:{}".format(group, name) + if group == "tower-processes": + sys.stderr.write('Restarting %s\n' % program) + rpc.supervisor.stopProcess(program) + rpc.supervisor.startProcess(program) + + except: + sys.stderr.write("No previous hash found") + write_hash("/var/lib/awx/.configsha") + childutils.listener.ok(sys.stdout) From 56abfa732efa38c1bfa707cb110bbca7c4fab817 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Tue, 14 Nov 2017 13:49:06 -0500 Subject: [PATCH 29/82] Adding initial instance group policies and policy evaluation planner --- awx/api/serializers.py | 3 +- .../management/commands/register_queue.py | 10 ++++- .../0013_v330_instancegroup_policies.py | 30 ++++++++++++++ awx/main/models/ha.py | 14 +++++++ awx/main/tasks.py | 40 ++++++++++++++++++- 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 awx/main/migrations/0013_v330_instancegroup_policies.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index a0822cea0f..adb17dd65b 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4011,7 +4011,8 @@ class InstanceGroupSerializer(BaseSerializer): model = InstanceGroup fields = ("id", "type", "url", "related", "name", "created", "modified", "capacity", "committed_capacity", "consumed_capacity", - "percent_capacity_remaining", "jobs_running", "instances", "controller") + "percent_capacity_remaining", "jobs_running", "instances", "controller", + "policy_instance_percentage", "policy_instance_minimum") def get_related(self, obj): res = super(InstanceGroupSerializer, self).get_related(obj) diff --git a/awx/main/management/commands/register_queue.py b/awx/main/management/commands/register_queue.py index 548e305bcc..1e7912836d 100644 --- a/awx/main/management/commands/register_queue.py +++ b/awx/main/management/commands/register_queue.py @@ -17,6 +17,10 @@ class Command(BaseCommand): help='Comma-Delimited Hosts to add to the Queue') parser.add_argument('--controller', dest='controller', type=str, default='', help='The controlling group (makes this an isolated group)') + parser.add_argument('--instance_percent', dest='instance_percent', type=int, default=0, + help='The percentage of active instances that will be assigned to this group'), + 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 handle(self, **options): queuename = options.get('queuename') @@ -38,7 +42,9 @@ class Command(BaseCommand): changed = True else: print("Creating instance group {}".format(queuename)) - ig = InstanceGroup(name=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() @@ -60,5 +66,7 @@ class Command(BaseCommand): 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)') diff --git a/awx/main/migrations/0013_v330_instancegroup_policies.py b/awx/main/migrations/0013_v330_instancegroup_policies.py new file mode 100644 index 0000000000..b8fa658fb8 --- /dev/null +++ b/awx/main/migrations/0013_v330_instancegroup_policies.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0008_v320_drop_v1_credential_fields'), + ] + + operations = [ + migrations.AddField( + model_name='instancegroup', + name='policy_instance_list', + field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', blank=True), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_minimum', + field=models.IntegerField(default=0, help_text='Static minimum number of Instances to automatically assign to this group'), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_percentage', + field=models.IntegerField(default=0, help_text='Percentage of Instances to automatically assign to this group'), + ), + ] diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index cb63beb126..eac0bec22f 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -12,6 +12,7 @@ from solo.models import SingletonModel from awx.api.versioning import reverse from awx.main.managers import InstanceManager, InstanceGroupManager +from awx.main.fields import JSONField from awx.main.models.inventory import InventoryUpdate from awx.main.models.jobs import Job from awx.main.models.projects import ProjectUpdate @@ -88,6 +89,19 @@ class InstanceGroup(models.Model): default=None, null=True ) + policy_instance_percentage = models.IntegerField( + default=0, + help_text=_("Percentage of Instances to automatically assign to this group") + ) + policy_instance_minimum = models.IntegerField( + default=0, + help_text=_("Static minimum number of Instances to automatically assign to this group") + ) + policy_instance_list = JSONField( + default=[], + blank=True, + help_text=_("List of exact-match Instances that will always be automatically assigned to this group") + ) def get_absolute_url(self, request=None): return reverse('api:instance_group_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 0e9c8bcea5..109b92a771 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2,7 +2,8 @@ # All Rights Reserved. # Python -from collections import OrderedDict +import codecs +from collections import OrderedDict, namedtuple import ConfigParser import cStringIO import functools @@ -131,6 +132,43 @@ def inform_cluster_of_shutdown(*args, **kwargs): logger.exception('Encountered problem with normal shutdown signal.') +@shared_task(bind=True, queue='tower', base=LogErrorsTask) +def apply_cluster_membership_policies(self): + considered_instances = Instance.objects.all().order_by('id').only('id') + total_instances = considered_instances.count() + actual_groups = [] + actual_instances = [] + Group = namedtuple('Group', ['obj', 'instances']) + Instance = 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(): + group_actual = Group(obj=ig, instances=[]) + for i in ig.policy_instance_list: + group_actual.instances.append(i) + if i in considered_instances: + considered_instances.remove(i) + actual_groups.append(group_actual) + # Process Instance minimum policies next, since it represents a concrete lower bound to the + # number of instances to make available to instance groups + for i in considered_instances: + instance_actual = Instance(obj=i, groups=[]) + for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): + if len(g.instances) < g.obj.policy_instance_minimum: + g.instances.append(instance_actual.obj.id) + instance_actual.groups.append(g.obj.id) + break + actual_instances.append(instance_actual) + # Finally process instance policy percentages + for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): + for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): + if 100 * float(len(g.instances)) / total_instances < g.obj.policy_instance_percentage: + g.instances.append(i.obj.id) + i.groups.append(g.obj.id) + break + # Next step + + @shared_task(queue='tower_broadcast_all', bind=True, base=LogErrorsTask) def handle_setting_changes(self, setting_keys): orig_len = len(setting_keys) From d9e774c4b680e9c797a72452b1fa28e40f86dfd3 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 16 Nov 2017 14:55:17 -0500 Subject: [PATCH 30/82] Updates for automatic triggering of policies * Switch policy router queue to not be "tower" so that we don't fall into a chicken/egg scenario * Show fixed policy list in serializer so a user can determine if an instance is manually managed * Change IG membership mixin to not directly handle applying topology changes. Instead it just makes sure the policy instance list is accurate * Add create/delete hooks for instances and groups to trigger policy re-evaluation * Update policy algorithm for fairer distribution * Fix an issue where CELERY_ROUTES wasn't renamed after celery/django upgrade * Update unit tests to be more explicit * Update count calculations used by algorithm to only consider non-manual instances * Adding unit tests and fixture * Don't propagate logging messages from awx.main.tasks and awx.main.scheduler * Use advisory lock to prevent policy eval conflicts * Allow updating instance groups from view --- Makefile | 4 +- awx/api/serializers.py | 2 +- awx/api/views.py | 32 ++++--- ...py => 0018_v330_instancegroup_policies.py} | 5 +- awx/main/models/ha.py | 28 +++++- awx/main/tasks.py | 88 +++++++++++-------- awx/main/tests/factories/fixtures.py | 5 +- awx/main/tests/factories/tower.py | 4 +- .../task_management/test_rampart_groups.py | 33 +++++++ awx/main/tests/unit/utils/test_ha.py | 8 +- awx/main/utils/ha.py | 9 +- awx/settings/defaults.py | 5 +- .../image_build/files/launch_awx_task.sh | 2 +- .../image_build/files/supervisor_task.conf | 2 +- 14 files changed, 159 insertions(+), 68 deletions(-) rename awx/main/migrations/{0013_v330_instancegroup_policies.py => 0018_v330_instancegroup_policies.py} (87%) diff --git a/Makefile b/Makefile index 99e4d0c325..61492e4bf4 100644 --- a/Makefile +++ b/Makefile @@ -216,13 +216,11 @@ init: . $(VENV_BASE)/awx/bin/activate; \ fi; \ $(MANAGEMENT_COMMAND) provision_instance --hostname=$(COMPOSE_HOST); \ - $(MANAGEMENT_COMMAND) register_queue --queuename=tower --hostnames=$(COMPOSE_HOST);\ + $(MANAGEMENT_COMMAND) register_queue --queuename=tower --instance_percent=100;\ if [ "$(AWX_GROUP_QUEUES)" == "tower,thepentagon" ]; then \ $(MANAGEMENT_COMMAND) provision_instance --hostname=isolated; \ $(MANAGEMENT_COMMAND) register_queue --queuename='thepentagon' --hostnames=isolated --controller=tower; \ $(MANAGEMENT_COMMAND) generate_isolated_key | ssh -o "StrictHostKeyChecking no" root@isolated 'cat > /root/.ssh/authorized_keys'; \ - elif [ "$(AWX_GROUP_QUEUES)" != "tower" ]; then \ - $(MANAGEMENT_COMMAND) register_queue --queuename=$(firstword $(subst $(comma), ,$(AWX_GROUP_QUEUES))) --hostnames=$(COMPOSE_HOST); \ fi; # Refresh development environment after pulling new code. diff --git a/awx/api/serializers.py b/awx/api/serializers.py index adb17dd65b..f134d393e6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -4012,7 +4012,7 @@ class InstanceGroupSerializer(BaseSerializer): fields = ("id", "type", "url", "related", "name", "created", "modified", "capacity", "committed_capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running", "instances", "controller", - "policy_instance_percentage", "policy_instance_minimum") + "policy_instance_percentage", "policy_instance_minimum", "policy_instance_list") def get_related(self, obj): res = super(InstanceGroupSerializer, self).get_related(obj) diff --git a/awx/api/views.py b/awx/api/views.py index df7d2b61e8..56bee58c21 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -57,7 +57,7 @@ import pytz from wsgiref.util import FileWrapper # AWX -from awx.main.tasks import send_notifications, handle_ha_toplogy_changes +from awx.main.tasks import send_notifications from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TokenGetAuthentication @@ -154,20 +154,32 @@ class InstanceGroupMembershipMixin(object): ''' def attach(self, request, *args, **kwargs): response = super(InstanceGroupMembershipMixin, self).attach(request, *args, **kwargs) + sub_id, res = self.attach_validate(request) if status.is_success(response.status_code): - handle_ha_toplogy_changes.apply_async() + if self.parent_model is Instance: + ig_obj = get_object_or_400(self.model, pk=sub_id) + inst_name = ig_obj.hostname + else: + ig_obj = self.get_parent_object() + inst_name = get_object_or_400(self.model, pk=sub_id).hostname + if inst_name not in ig_obj.policy_instance_list: + ig_obj.policy_instance_list.append(inst_name) + ig_obj.save() return response def unattach(self, request, *args, **kwargs): response = super(InstanceGroupMembershipMixin, self).unattach(request, *args, **kwargs) + sub_id, res = self.attach_validate(request) if status.is_success(response.status_code): - handle_ha_toplogy_changes.apply_async() - return response - - def destroy(self, request, *args, **kwargs): - response = super(InstanceGroupMembershipMixin, self).destroy(request, *args, **kwargs) - if status.is_success(response.status_code): - handle_ha_toplogy_changes.apply_async() + if self.parent_model is Instance: + ig_obj = get_object_or_400(self.model, pk=sub_id) + inst_name = self.get_parent_object().hostname + else: + ig_obj = self.get_parent_object() + inst_name = get_object_or_400(self.model, pk=sub_id).hostname + if inst_name in ig_obj.policy_instance_list: + ig_obj.policy_instance_list.pop(ig_obj.policy_instance_list.index(inst_name)) + ig_obj.save() return response @@ -589,7 +601,7 @@ class InstanceGroupList(ListCreateAPIView): new_in_320 = True -class InstanceGroupDetail(InstanceGroupMembershipMixin, RetrieveDestroyAPIView): +class InstanceGroupDetail(RetrieveUpdateDestroyAPIView): view_name = _("Instance Group Detail") model = InstanceGroup diff --git a/awx/main/migrations/0013_v330_instancegroup_policies.py b/awx/main/migrations/0018_v330_instancegroup_policies.py similarity index 87% rename from awx/main/migrations/0013_v330_instancegroup_policies.py rename to awx/main/migrations/0018_v330_instancegroup_policies.py index b8fa658fb8..63403f6766 100644 --- a/awx/main/migrations/0013_v330_instancegroup_policies.py +++ b/awx/main/migrations/0018_v330_instancegroup_policies.py @@ -8,14 +8,15 @@ import awx.main.fields class Migration(migrations.Migration): dependencies = [ - ('main', '0008_v320_drop_v1_credential_fields'), + ('main', '0017_v330_move_deprecated_stdout'), ] operations = [ migrations.AddField( model_name='instancegroup', name='policy_instance_list', - field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', blank=True), + field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', + blank=True), ), migrations.AddField( model_name='instancegroup', diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index eac0bec22f..12e8bf23e9 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -2,7 +2,7 @@ # All Rights Reserved. from django.db import models -from django.db.models.signals import post_save +from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ from django.conf import settings @@ -136,6 +136,32 @@ class JobOrigin(models.Model): app_label = 'main' +@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 + apply_cluster_membership_policies.apply_async(countdown=5) + + +@receiver(post_save, sender=Instance) +def on_instance_saved(sender, instance, created=False, raw=False, **kwargs): + if created: + from awx.main.tasks import apply_cluster_membership_policies + apply_cluster_membership_policies.apply_async(countdown=5) + + +@receiver(post_delete, sender=InstanceGroup) +def on_instance_group_deleted(sender, instance, using, **kwargs): + from awx.main.tasks import apply_cluster_membership_policies + apply_cluster_membership_policies.apply_async(countdown=5) + + +@receiver(post_delete, sender=Instance) +def on_instance_deleted(sender, instance, using, **kwargs): + from awx.main.tasks import apply_cluster_membership_policies + apply_cluster_membership_policies.apply_async(countdown=5) + + # Unfortunately, the signal can't just be connected against UnifiedJob; it # turns out that creating a model's subclass doesn't fire the signal for the # superclass model. diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 109b92a771..698fda006e 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -26,7 +26,7 @@ except Exception: psutil = None # Celery -from celery import Task, shared_task +from celery import Task, shared_task, Celery from celery.signals import celeryd_init, worker_process_init, worker_shutdown, worker_ready, celeryd_after_setup # Django @@ -58,13 +58,14 @@ from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, ignore_inventory_computed_fields, ignore_inventory_group_removal, get_type_for_model, extract_ansible_vars) 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 from awx.main.utils.handlers import configure_external_logger from awx.main.consumers import emit_channel_notification from awx.conf import settings_registry __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', - 'RunAdHocCommand', 'handle_work_error', 'handle_work_success', + 'RunAdHocCommand', 'handle_work_error', 'handle_work_success', 'apply_cluster_membership_policies', 'update_inventory_computed_fields', 'update_host_smart_inventory_memberships', 'send_notifications', 'run_administrative_checks', 'purge_old_stdout_files'] @@ -132,41 +133,54 @@ def inform_cluster_of_shutdown(*args, **kwargs): logger.exception('Encountered problem with normal shutdown signal.') -@shared_task(bind=True, queue='tower', base=LogErrorsTask) +@shared_task(bind=True, queue='tower_instance_router', base=LogErrorsTask) def apply_cluster_membership_policies(self): - considered_instances = Instance.objects.all().order_by('id').only('id') - total_instances = considered_instances.count() - actual_groups = [] - actual_instances = [] - Group = namedtuple('Group', ['obj', 'instances']) - Instance = 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(): - group_actual = Group(obj=ig, instances=[]) - for i in ig.policy_instance_list: - group_actual.instances.append(i) - if i in considered_instances: - considered_instances.remove(i) - actual_groups.append(group_actual) - # Process Instance minimum policies next, since it represents a concrete lower bound to the - # number of instances to make available to instance groups - for i in considered_instances: - instance_actual = Instance(obj=i, groups=[]) + with advisory_lock('cluster_policy_lock', wait=True): + considered_instances = Instance.objects.all().order_by('id') + total_instances = considered_instances.count() + filtered_instances = [] + actual_groups = [] + 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(): + logger.info("Considering group {}".format(ig.name)) + ig.instances.clear() + group_actual = Group(obj=ig, instances=[]) + for i in ig.policy_instance_list: + inst = Instance.objects.filter(hostname=i) + if not inst.exists(): + continue + inst = inst[0] + logger.info("Policy List, adding {} to {}".format(inst.hostname, ig.name)) + group_actual.instances.append(inst.id) + ig.instances.add(inst) + filtered_instances.append(inst) + actual_groups.append(group_actual) + # Process Instance minimum policies next, since it represents a concrete lower bound to the + # number of instances to make available to instance groups + actual_instances = [Node(obj=i, groups=[]) for i in filter(lambda x: x not in filtered_instances, considered_instances)] + logger.info("Total instances not directly associated: {}".format(total_instances)) for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): - if len(g.instances) < g.obj.policy_instance_minimum: - g.instances.append(instance_actual.obj.id) - instance_actual.groups.append(g.obj.id) - break - actual_instances.append(instance_actual) - # Finally process instance policy percentages - for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): - for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): - if 100 * float(len(g.instances)) / total_instances < g.obj.policy_instance_percentage: + for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): + if len(g.instances) >= g.obj.policy_instance_minimum: + break + logger.info("Policy minimum, adding {} to {}".format(i.obj.hostname, g.obj.name)) + g.obj.instances.add(i.obj) g.instances.append(i.obj.id) i.groups.append(g.obj.id) - break - # Next step + # Finally process instance policy percentages + for g in sorted(actual_groups, cmp=lambda x,y: len(x.instances) - len(y.instances)): + for i in sorted(actual_instances, cmp=lambda x,y: len(x.groups) - len(y.groups)): + if 100 * float(len(g.instances)) / len(actual_instances) >= g.obj.policy_instance_percentage: + break + logger.info("Policy percentage, adding {} to {}".format(i.obj.hostname, g.obj.name)) + g.instances.append(i.obj.id) + g.obj.instances.add(i.obj) + i.groups.append(g.obj.id) + handle_ha_toplogy_changes.apply_async() @shared_task(queue='tower_broadcast_all', bind=True, base=LogErrorsTask) @@ -190,12 +204,14 @@ def handle_setting_changes(self, setting_keys): def handle_ha_toplogy_changes(self): instance = Instance.objects.me() logger.debug("Reconfigure celeryd queues task on host {}".format(self.request.hostname)) - (instance, removed_queues, added_queues) = register_celery_worker_queues(self.app, self.request.hostname) + awx_app = Celery('awx') + awx_app.config_from_object('django.conf:settings', namespace='CELERY') + (instance, removed_queues, added_queues) = register_celery_worker_queues(awx_app, self.request.hostname) logger.info("Workers on tower node '{}' removed from queues {} and added to queues {}" .format(instance.hostname, removed_queues, added_queues)) updated_routes = update_celery_worker_routes(instance, settings) logger.info("Worker on tower node '{}' updated celery routes {} all routes are now {}" - .format(instance.hostname, updated_routes, self.app.conf.CELERY_ROUTES)) + .format(instance.hostname, updated_routes, self.app.conf.CELERY_TASK_ROUTES)) @worker_ready.connect @@ -213,7 +229,7 @@ def handle_update_celery_routes(sender=None, conf=None, **kwargs): instance = Instance.objects.me() added_routes = update_celery_worker_routes(instance, conf) logger.info("Workers on tower node '{}' added routes {} all routes are now {}" - .format(instance.hostname, added_routes, conf.CELERY_ROUTES)) + .format(instance.hostname, added_routes, conf.CELERY_TASK_ROUTES)) @celeryd_after_setup.connect diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index eee545336d..2f3ec0656f 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -35,8 +35,9 @@ def mk_instance(persisted=True, hostname='instance.example.org'): return Instance.objects.get_or_create(uuid=settings.SYSTEM_UUID, hostname=hostname)[0] -def mk_instance_group(name='tower', instance=None): - ig, status = InstanceGroup.objects.get_or_create(name=name) +def mk_instance_group(name='tower', instance=None, minimum=0, percentage=0): + ig, status = InstanceGroup.objects.get_or_create(name=name, policy_instance_minimum=minimum, + policy_instance_percentage=percentage) if instance is not None: if type(instance) == list: for i in instance: diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py index a8f20f941f..ecb395dd99 100644 --- a/awx/main/tests/factories/tower.py +++ b/awx/main/tests/factories/tower.py @@ -135,8 +135,8 @@ def create_instance(name, instance_groups=None): return mk_instance(hostname=name) -def create_instance_group(name, instances=None): - return mk_instance_group(name=name, instance=instances) +def create_instance_group(name, instances=None, minimum=0, percentage=0): + return mk_instance_group(name=name, instance=instances, minimum=minimum, percentage=percentage) def create_survey_spec(variables=None, default_type='integer', required=True, min=None, max=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 f4c6ba95bf..9b4b3eac44 100644 --- a/awx/main/tests/functional/task_management/test_rampart_groups.py +++ b/awx/main/tests/functional/task_management/test_rampart_groups.py @@ -2,6 +2,8 @@ import pytest import mock from datetime import timedelta from awx.main.scheduler import TaskManager +from awx.main.models import InstanceGroup +from awx.main.tasks import apply_cluster_membership_policies @pytest.mark.django_db @@ -151,3 +153,34 @@ def test_failover_group_run(instance_factory, default_instance_group, mocker, tm.schedule() mock_job.assert_has_calls([mock.call(j1, ig1, []), mock.call(j1_1, ig2, [])]) assert mock_job.call_count == 2 + + +@pytest.mark.django_db +def test_instance_group_basic_policies(instance_factory, instance_group_factory): + i0 = instance_factory("i0") + i1 = instance_factory("i1") + i2 = instance_factory("i2") + i3 = instance_factory("i3") + i4 = instance_factory("i4") + ig0 = instance_group_factory("ig0") + ig1 = instance_group_factory("ig1", minimum=2) + ig2 = instance_group_factory("ig2", percentage=50) + ig3 = instance_group_factory("ig3", percentage=50) + ig0.policy_instance_list.append(i0.hostname) + ig0.save() + apply_cluster_membership_policies() + ig0 = InstanceGroup.objects.get(id=ig0.id) + ig1 = InstanceGroup.objects.get(id=ig1.id) + ig2 = InstanceGroup.objects.get(id=ig2.id) + ig3 = InstanceGroup.objects.get(id=ig3.id) + assert len(ig0.instances.all()) == 1 + assert i0 in ig0.instances.all() + assert len(InstanceGroup.objects.get(id=ig1.id).instances.all()) == 2 + assert i1 in ig1.instances.all() + assert i2 in ig1.instances.all() + assert len(InstanceGroup.objects.get(id=ig2.id).instances.all()) == 2 + assert i3 in ig2.instances.all() + assert i4 in ig2.instances.all() + assert len(InstanceGroup.objects.get(id=ig3.id).instances.all()) == 2 + assert i1 in ig3.instances.all() + assert i2 in ig3.instances.all() diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py index 6bd1b856b9..b71ca454e1 100644 --- a/awx/main/tests/unit/utils/test_ha.py +++ b/awx/main/tests/unit/utils/test_ha.py @@ -17,7 +17,7 @@ from awx.main.utils.ha import ( @pytest.fixture def conf(): class Conf(): - CELERY_ROUTES = dict() + CELERY_TASK_ROUTES = dict() CELERYBEAT_SCHEDULE = dict() return Conf() @@ -87,14 +87,14 @@ class TestUpdateCeleryWorkerRoutes(): instance.is_controller = mocker.MagicMock(return_value=is_controller) assert update_celery_worker_routes(instance, conf) == expected_routes - assert conf.CELERY_ROUTES == expected_routes + assert conf.CELERY_TASK_ROUTES == expected_routes def test_update_celery_worker_routes_deleted(self, mocker, conf): instance = mocker.MagicMock() instance.hostname = 'east-1' instance.is_controller = mocker.MagicMock(return_value=False) - conf.CELERY_ROUTES = {'awx.main.tasks.awx_isolated_heartbeat': 'foobar'} + conf.CELERY_TASK_ROUTES = {'awx.main.tasks.awx_isolated_heartbeat': 'foobar'} update_celery_worker_routes(instance, conf) - assert 'awx.main.tasks.awx_isolated_heartbeat' not in conf.CELERY_ROUTES + assert 'awx.main.tasks.awx_isolated_heartbeat' not in conf.CELERY_TASK_ROUTES diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py index 9efb3e9cf3..376faf9eb9 100644 --- a/awx/main/utils/ha.py +++ b/awx/main/utils/ha.py @@ -14,6 +14,7 @@ def _add_remove_celery_worker_queues(app, instance, worker_queues, worker_name): removed_queues = [] added_queues = [] ig_names = set(instance.rampart_groups.values_list('name', flat=True)) + ig_names.add("tower_instance_router") worker_queue_names = set([q['name'] for q in worker_queues]) @@ -47,12 +48,12 @@ def update_celery_worker_routes(instance, conf): if instance.is_controller(): tasks.append('awx.main.tasks.awx_isolated_heartbeat') else: - if 'awx.main.tasks.awx_isolated_heartbeat' in conf.CELERY_ROUTES: - del conf.CELERY_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] + if 'awx.main.tasks.awx_isolated_heartbeat' in conf.CELERY_TASK_ROUTES: + del conf.CELERY_TASK_ROUTES['awx.main.tasks.awx_isolated_heartbeat'] for t in tasks: - conf.CELERY_ROUTES[t] = {'queue': instance.hostname, 'routing_key': instance.hostname} - routes_updated[t] = conf.CELERY_ROUTES[t] + conf.CELERY_TASK_ROUTES[t] = {'queue': instance.hostname, 'routing_key': instance.hostname} + routes_updated[t] = conf.CELERY_TASK_ROUTES[t] return routes_updated diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index db3dec23fb..5fb4edfe69 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -432,6 +432,7 @@ DEVSERVER_DEFAULT_PORT = '8013' # Set default ports for live server tests. os.environ.setdefault('DJANGO_LIVE_TEST_SERVER_ADDRESS', 'localhost:9013-9199') +BROKER_POOL_LIMIT = None CELERY_BROKER_URL = 'amqp://guest:guest@localhost:5672//' CELERY_EVENT_QUEUE_TTL = 5 CELERY_TASK_DEFAULT_QUEUE = 'tower' @@ -452,7 +453,7 @@ CELERY_TASK_QUEUES = ( ) CELERY_TASK_ROUTES = {} -CELERYBEAT_SCHEDULE = { +CELERY_BEAT_SCHEDULE = { 'tower_scheduler': { 'task': 'awx.main.tasks.awx_periodic_scheduler', 'schedule': timedelta(seconds=30), @@ -1123,9 +1124,11 @@ LOGGING = { }, 'awx.main.tasks': { 'handlers': ['task_system'], + 'propagate': False }, 'awx.main.scheduler': { 'handlers': ['task_system'], + 'propagate': False }, 'awx.main.consumers': { 'handlers': ['null'] diff --git a/installer/image_build/files/launch_awx_task.sh b/installer/image_build/files/launch_awx_task.sh index 88b59d63fa..ebcc8b6798 100755 --- a/installer/image_build/files/launch_awx_task.sh +++ b/installer/image_build/files/launch_awx_task.sh @@ -19,5 +19,5 @@ else awx-manage create_preload_data fi awx-manage provision_instance --hostname=$(hostname) -awx-manage register_queue --queuename=tower --hostnames=$(hostname) +awx-manage register_queue --queuename=tower --instance_percent=100 supervisord -c /supervisor_task.conf diff --git a/installer/image_build/files/supervisor_task.conf b/installer/image_build/files/supervisor_task.conf index 1a4e613925..83107bf6e7 100644 --- a/installer/image_build/files/supervisor_task.conf +++ b/installer/image_build/files/supervisor_task.conf @@ -3,7 +3,7 @@ nodaemon = True umask = 022 [program:celery] -command = /var/lib/awx/venv/awx/bin/celery worker -A awx -B -l debug --autoscale=4 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@$(ENV_HOSTNAME)s +command = /var/lib/awx/venv/awx/bin/celery worker -A awx -B -l debug --autoscale=4 -Ofair -s /var/lib/awx/beat.db -Q tower_broadcast_all -n celery@%(ENV_HOSTNAME)s directory = /var/lib/awx environment = LANGUAGE="en_US.UTF-8",LANG="en_US.UTF-8",LC_ALL="en_US.UTF-8",LC_CTYPE="en_US.UTF-8" #user = {{ aw_user }} From 6e9930a45f32eff2c28104d31d63d5fa832157d6 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 29 Nov 2017 15:14:56 -0500 Subject: [PATCH 31/82] Use on_commit hook for triggering ig policy * also Apply console handlers to loggers for dev environment --- awx/main/models/ha.py | 10 ++++----- awx/settings/local_settings.py.docker_compose | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index 12e8bf23e9..c7a10b5e00 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -1,7 +1,7 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. -from django.db import models +from django.db import models, connection from django.db.models.signals import post_save, post_delete from django.dispatch import receiver from django.utils.translation import ugettext_lazy as _ @@ -140,26 +140,26 @@ class JobOrigin(models.Model): def on_instance_group_saved(sender, instance, created=False, raw=False, **kwargs): if created: from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) @receiver(post_save, sender=Instance) def on_instance_saved(sender, instance, created=False, raw=False, **kwargs): if created: from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) @receiver(post_delete, sender=InstanceGroup) def on_instance_group_deleted(sender, instance, using, **kwargs): from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) @receiver(post_delete, sender=Instance) def on_instance_deleted(sender, instance, using, **kwargs): from awx.main.tasks import apply_cluster_membership_policies - apply_cluster_membership_policies.apply_async(countdown=5) + connection.on_commit(lambda: apply_cluster_membership_policies.apply_async()) # Unfortunately, the signal can't just be connected against UnifiedJob; it diff --git a/awx/settings/local_settings.py.docker_compose b/awx/settings/local_settings.py.docker_compose index ff0048e102..84592c21bd 100644 --- a/awx/settings/local_settings.py.docker_compose +++ b/awx/settings/local_settings.py.docker_compose @@ -198,6 +198,27 @@ LOGGING['handlers']['syslog'] = { 'formatter': 'simple', } +LOGGING['loggers']['django.request']['handlers'] = ['console'] +LOGGING['loggers']['rest_framework.request']['handlers'] = ['console'] +LOGGING['loggers']['awx']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.commands.run_callback_receiver']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.commands.inventory_import']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.tasks']['handlers'] = ['console'] +LOGGING['loggers']['awx.main.scheduler']['handlers'] = ['console'] +LOGGING['loggers']['django_auth_ldap']['handlers'] = ['console'] +LOGGING['loggers']['social']['handlers'] = ['console'] +LOGGING['loggers']['system_tracking_migrations']['handlers'] = ['console'] +LOGGING['loggers']['rbac_migrations']['handlers'] = ['console'] +LOGGING['loggers']['awx.isolated.manager.playbooks']['handlers'] = ['console'] +LOGGING['handlers']['callback_receiver'] = {'class': 'logging.NullHandler'} +LOGGING['handlers']['fact_receiver'] = {'class': 'logging.NullHandler'} +LOGGING['handlers']['task_system'] = {'class': 'logging.NullHandler'} +LOGGING['handlers']['tower_warnings'] = {'class': 'logging.NullHandler'} +LOGGING['handlers']['rbac_migrations'] = {'class': 'logging.NullHandler'} +LOGGING['handlers']['system_tracking_migrations'] = {'class': 'logging.NullHandler'} +LOGGING['handlers']['management_playbooks'] = {'class': 'logging.NullHandler'} + + # Enable the following lines to also log to a file. #LOGGING['handlers']['file'] = { # 'class': 'logging.FileHandler', From 6a85fc38dd861633bc795e14155271f73b816b40 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 11 Jan 2018 12:16:14 -0500 Subject: [PATCH 32/82] Add scalable cluster kubernetes support --- installer/kubernetes/tasks/main.yml | 9 ++++ .../kubernetes/templates/configmap.yml.j2 | 2 + .../kubernetes/templates/deployment.yml.j2 | 30 +++++++++++-- installer/kubernetes/templates/etcd.yml.j2 | 44 +++++++++++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 installer/kubernetes/templates/etcd.yml.j2 diff --git a/installer/kubernetes/tasks/main.yml b/installer/kubernetes/tasks/main.yml index bd9d63677d..53916f56ba 100644 --- a/installer/kubernetes/tasks/main.yml +++ b/installer/kubernetes/tasks/main.yml @@ -96,6 +96,12 @@ path: "{{ kubernetes_base_path }}" state: directory +- name: Template Kubernetes AWX etcd2 + template: + src: etcd.yml.j2 + dest: "{{ kubernetes_base_path }}/etcd.yml" + mode: '0600' + - name: Template Kubernetes AWX Config template: src: configmap.yml.j2 @@ -108,6 +114,9 @@ dest: "{{ kubernetes_base_path }}/deployment.yml" mode: '0600' +- name: Apply etcd deployment + shell: "kubectl apply -f {{ kubernetes_base_path }}/etcd.yml" + - name: Apply Configmap shell: "kubectl apply -f {{ kubernetes_base_path }}/configmap.yml" diff --git a/installer/kubernetes/templates/configmap.yml.j2 b/installer/kubernetes/templates/configmap.yml.j2 index 9aafb888fd..fa61fcda83 100644 --- a/installer/kubernetes/templates/configmap.yml.j2 +++ b/installer/kubernetes/templates/configmap.yml.j2 @@ -13,6 +13,8 @@ data: # Container environments don't like chroots AWX_PROOT_ENABLED = False + AWX_AUTO_DEPROVISION_INSTANCES = True + #Autoprovisioning should replace this CLUSTER_HOST_ID = socket.gethostname() SYSTEM_UUID = '00000000-0000-0000-0000-000000000000' diff --git a/installer/kubernetes/templates/deployment.yml.j2 b/installer/kubernetes/templates/deployment.yml.j2 index 8b7b0580f8..318723181b 100644 --- a/installer/kubernetes/templates/deployment.yml.j2 +++ b/installer/kubernetes/templates/deployment.yml.j2 @@ -41,18 +41,42 @@ spec: - name: AWX_ADMIN_PASSWORD value: {{ default_admin_password|default('password') }} - name: awx-rabbit - image: rabbitmq:3 + image: ansible/awx_rabbitmq:latest + imagePullPolicy: Always env: + # For consupmption by rabbitmq-env.conf + - name: MY_POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: RABBITMQ_USE_LONGNAME + value: "true" + - name: ERLANG_COOKIE + value: "test" - name: RABBITMQ_ERLANG_COOKIE - value: secretb + value: "secretb" - name: RABBITMQ_NODENAME - value: rabbitmq + value: "rabbit@$(MY_POD_IP)" + - name: AUTOCLUSTER_TYPE + value: "etcd" + - name: AUTOCLUSTER_DELAY + value: "60" + - name: ETCD_HOST + value: "etcd" + - name: AUTOCLUSTER_CLEANUP + value: "true" + - name: CLEANUP_WARN_ONLY + value: "false" + - name: CLEANUP_INTERVAL + value: "30" - name: RABBITMQ_DEFAULT_USER value: awx - name: RABBITMQ_DEFAULT_PASS value: abcdefg - name: RABBITMQ_DEFAULT_VHOST value: awx + - name: RABBITMQ_CONFIG_FILE + value: /etc/rabbitmq/rabbitmq - name: awx-memcached image: memcached volumes: diff --git a/installer/kubernetes/templates/etcd.yml.j2 b/installer/kubernetes/templates/etcd.yml.j2 new file mode 100644 index 0000000000..8e8977d6c6 --- /dev/null +++ b/installer/kubernetes/templates/etcd.yml.j2 @@ -0,0 +1,44 @@ +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: etcd + namespace: {{ awx_kubernetes_project }} +spec: + replicas: 1 + template: + metadata: + labels: + name: awx-etcd2 + service: etcd + spec: + containers: + - name: etcd + image: elcolio/etcd:latest + ports: + - containerPort: 4001 + volumeMounts: + - mountPath: /data + name: datadir + volumes: + - name: datadir + emptyDir: {} +--- +apiVersion: v1 +kind: Service +metadata: + annotations: + labels: + name: awx-etcd + name: etcd + namespace: {{ awx_kubernetes_project }} +spec: + ports: + - name: etcd + port: 4001 + protocol: TCP + targetPort: 4001 + selector: + name: awx-etcd2 + sessionAffinity: None + type: ClusterIP From 70bf78e29fd8b3eb0bc581e9c6ae58ac923f63a9 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 11 Jan 2018 13:33:35 -0500 Subject: [PATCH 33/82] Apply capacity algorithm changes * This also adds fields to the instance view for tracking cpu and memory usage as well as information on what the capacity ranges are * Also adds a flag for enabling/disabling instances which removes them from all queues and has them stop processing new work * The capacity is now based almost exclusively on some value relative to forks * capacity_adjustment allows you to commit an instance to a certain amount of forks, cpu focused or memory focused * Each job run adds a single fork overhead (that's the reasoning behind the +1) --- awx/api/serializers.py | 6 +- awx/api/views.py | 18 +++++- awx/main/managers.py | 3 - .../0018_v330_instancegroup_policies.py | 31 ---------- .../0020_v330_instancegroup_policies.py | 62 +++++++++++++++++++ awx/main/models/ad_hoc_commands.py | 2 +- awx/main/models/ha.py | 42 +++++++++++++ awx/main/models/inventory.py | 2 +- awx/main/models/jobs.py | 4 +- awx/main/models/projects.py | 2 +- awx/main/tasks.py | 21 ++++--- awx/main/tests/functional/test_jobs.py | 26 +++++--- awx/main/tests/unit/utils/test_ha.py | 1 + awx/main/utils/common.py | 55 +++++++++++++--- awx/main/utils/ha.py | 3 +- awx/settings/defaults.py | 3 - docs/clustering.md | 43 +++++++++++++ 17 files changed, 248 insertions(+), 76 deletions(-) delete mode 100644 awx/main/migrations/0018_v330_instancegroup_policies.py create mode 100644 awx/main/migrations/0020_v330_instancegroup_policies.py diff --git a/awx/api/serializers.py b/awx/api/serializers.py index f134d393e6..003a80e317 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3977,8 +3977,10 @@ class InstanceSerializer(BaseSerializer): class Meta: model = Instance - fields = ("id", "type", "url", "related", "uuid", "hostname", "created", "modified", - "version", "capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running") + read_only_fields = ('uuid', 'hostname', 'version') + fields = ("id", "type", "url", "related", "uuid", "hostname", "created", "modified", 'capacity_adjustment', + "version", "capacity", "consumed_capacity", "percent_capacity_remaining", "jobs_running", + "cpu", "memory", "cpu_capacity", "mem_capacity", "enabled") def get_related(self, obj): res = super(InstanceSerializer, self).get_related(obj) diff --git a/awx/api/views.py b/awx/api/views.py index 56bee58c21..4a87204248 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -57,7 +57,7 @@ import pytz from wsgiref.util import FileWrapper # AWX -from awx.main.tasks import send_notifications +from awx.main.tasks import send_notifications, handle_ha_toplogy_changes from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TokenGetAuthentication @@ -560,7 +560,7 @@ class InstanceList(ListAPIView): new_in_320 = True -class InstanceDetail(RetrieveAPIView): +class InstanceDetail(RetrieveUpdateAPIView): view_name = _("Instance Detail") model = Instance @@ -568,6 +568,20 @@ class InstanceDetail(RetrieveAPIView): new_in_320 = True + def update(self, request, *args, **kwargs): + r = super(InstanceDetail, self).update(request, *args, **kwargs) + if status.is_success(r.status_code): + obj = self.get_object() + if obj.enabled: + obj.refresh_capacity() + else: + obj.capacity = 0 + obj.save() + handle_ha_toplogy_changes.apply_async() + r.data = InstanceSerializer(obj, context=self.get_serializer_context()).to_representation(obj) + return r + + class InstanceUnifiedJobsList(SubListAPIView): view_name = _("Instance Running Jobs") diff --git a/awx/main/managers.py b/awx/main/managers.py index f6f2dfd5b7..70c402f672 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -2,12 +2,9 @@ # All Rights Reserved. import sys -from datetime import timedelta import logging from django.db import models -from django.utils.timezone import now -from django.db.models import Sum from django.conf import settings from awx.main.utils.filters import SmartFilter diff --git a/awx/main/migrations/0018_v330_instancegroup_policies.py b/awx/main/migrations/0018_v330_instancegroup_policies.py deleted file mode 100644 index 63403f6766..0000000000 --- a/awx/main/migrations/0018_v330_instancegroup_policies.py +++ /dev/null @@ -1,31 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.db import migrations, models -import awx.main.fields - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0017_v330_move_deprecated_stdout'), - ] - - operations = [ - migrations.AddField( - model_name='instancegroup', - name='policy_instance_list', - field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', - blank=True), - ), - migrations.AddField( - model_name='instancegroup', - name='policy_instance_minimum', - field=models.IntegerField(default=0, help_text='Static minimum number of Instances to automatically assign to this group'), - ), - migrations.AddField( - model_name='instancegroup', - name='policy_instance_percentage', - field=models.IntegerField(default=0, help_text='Percentage of Instances to automatically assign to this group'), - ), - ] diff --git a/awx/main/migrations/0020_v330_instancegroup_policies.py b/awx/main/migrations/0020_v330_instancegroup_policies.py new file mode 100644 index 0000000000..a6716352e9 --- /dev/null +++ b/awx/main/migrations/0020_v330_instancegroup_policies.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from decimal import Decimal +import awx.main.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0019_v330_custom_virtualenv'), + ] + + operations = [ + migrations.AddField( + model_name='instancegroup', + name='policy_instance_list', + field=awx.main.fields.JSONField(default=[], help_text='List of exact-match Instances that will always be automatically assigned to this group', + blank=True), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_minimum', + field=models.IntegerField(default=0, help_text='Static minimum number of Instances to automatically assign to this group'), + ), + migrations.AddField( + model_name='instancegroup', + name='policy_instance_percentage', + field=models.IntegerField(default=0, help_text='Percentage of Instances to automatically assign to this group'), + ), + migrations.AddField( + model_name='instance', + name='capacity_adjustment', + field=models.DecimalField(decimal_places=2, default=Decimal('1.0'), max_digits=3), + ), + migrations.AddField( + model_name='instance', + name='cpu', + field=models.IntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='memory', + field=models.BigIntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='cpu_capacity', + field=models.IntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='mem_capacity', + field=models.IntegerField(default=0, editable=False) + ), + migrations.AddField( + model_name='instance', + name='enabled', + field=models.BooleanField(default=True) + ) + ] diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py index 56137378d6..3913a4ace7 100644 --- a/awx/main/models/ad_hoc_commands.py +++ b/awx/main/models/ad_hoc_commands.py @@ -184,7 +184,7 @@ class AdHocCommand(UnifiedJob, JobNotificationMixin): # NOTE: We sorta have to assume the host count matches and that forks default to 5 from awx.main.models.inventory import Host count_hosts = Host.objects.filter( enabled=True, inventory__ad_hoc_commands__pk=self.pk).count() - return min(count_hosts, 5 if self.forks == 0 else self.forks) * 10 + return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1 def copy(self): data = {} diff --git a/awx/main/models/ha.py b/awx/main/models/ha.py index c7a10b5e00..bf1d7f8266 100644 --- a/awx/main/models/ha.py +++ b/awx/main/models/ha.py @@ -1,6 +1,8 @@ # Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. +from decimal import Decimal + from django.db import models, connection from django.db.models.signals import post_save, post_delete from django.dispatch import receiver @@ -10,6 +12,7 @@ from django.utils.timezone import now, timedelta from solo.models import SingletonModel +from awx import __version__ as awx_application_version from awx.api.versioning import reverse from awx.main.managers import InstanceManager, InstanceGroupManager from awx.main.fields import JSONField @@ -17,6 +20,7 @@ from awx.main.models.inventory import InventoryUpdate from awx.main.models.jobs import Job from awx.main.models.projects import ProjectUpdate from awx.main.models.unified_jobs import UnifiedJob +from awx.main.utils import get_cpu_capacity, get_mem_capacity, get_system_task_capacity __all__ = ('Instance', 'InstanceGroup', 'JobOrigin', 'TowerScheduleState',) @@ -39,6 +43,30 @@ class Instance(models.Model): default=100, editable=False, ) + capacity_adjustment = models.DecimalField( + default=Decimal(1.0), + max_digits=3, + decimal_places=2, + ) + enabled = models.BooleanField( + default=True + ) + cpu = models.IntegerField( + default=0, + editable=False, + ) + memory = models.BigIntegerField( + default=0, + editable=False, + ) + cpu_capacity = models.IntegerField( + default=0, + editable=False, + ) + mem_capacity = models.IntegerField( + default=0, + editable=False, + ) class Meta: app_label = 'main' @@ -68,6 +96,20 @@ class Instance(models.Model): return Instance.objects.filter(rampart_groups__controller__instances=self).exists() + def refresh_capacity(self): + cpu = get_cpu_capacity() + mem = get_mem_capacity() + self.capacity = get_system_task_capacity(self.capacity_adjustment) + self.cpu = cpu[0] + self.memory = mem[0] + self.cpu_capacity = cpu[1] + self.mem_capacity = mem[1] + self.version = awx_application_version + self.save(update_fields=['capacity', 'version', 'modified', 'cpu', + 'memory', 'cpu_capacity', 'mem_capacity']) + + + class InstanceGroup(models.Model): """A model representing a Queue/Group of AWX Instances.""" objects = InstanceGroupManager() diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index fc437e236e..832e66ea04 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1602,7 +1602,7 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions, JobNotificationMixin, @property def task_impact(self): - return 50 + return 1 # InventoryUpdate credential required # Custom and SCM InventoryUpdate credential not required diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 4d3213b12c..a50c222b70 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -623,7 +623,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana count_hosts = 1 else: count_hosts = Host.objects.filter(inventory__jobs__pk=self.pk).count() - return min(count_hosts, 5 if self.forks == 0 else self.forks) * 10 + return min(count_hosts, 5 if self.forks == 0 else self.forks) + 1 @property def successful_hosts(self): @@ -1190,7 +1190,7 @@ class SystemJob(UnifiedJob, SystemJobOptions, JobNotificationMixin): @property def task_impact(self): - return 150 + return 5 @property def preferred_instance_groups(self): diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index 5794d170d7..0ee6aac241 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -492,7 +492,7 @@ class ProjectUpdate(UnifiedJob, ProjectOptions, JobNotificationMixin, TaskManage @property def task_impact(self): - return 0 if self.job_type == 'run' else 20 + return 0 if self.job_type == 'run' else 1 @property def result_stdout(self): diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 698fda006e..d41f21b087 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -2,7 +2,6 @@ # All Rights Reserved. # Python -import codecs from collections import OrderedDict, namedtuple import ConfigParser import cStringIO @@ -54,9 +53,8 @@ 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, get_system_task_capacity, OutputEventFilter, - ignore_inventory_computed_fields, ignore_inventory_group_removal, - get_type_for_model, extract_ansible_vars) + wrap_args_with_proot, OutputEventFilter, ignore_inventory_computed_fields, + ignore_inventory_group_removal, get_type_for_model, extract_ansible_vars) 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 @@ -307,6 +305,7 @@ def cluster_node_heartbeat(self): instance_list = list(Instance.objects.filter(rampart_groups__controller__isnull=True).distinct()) this_inst = None lost_instances = [] + for inst in list(instance_list): if inst.hostname == settings.CLUSTER_HOST_ID: this_inst = inst @@ -316,11 +315,15 @@ def cluster_node_heartbeat(self): instance_list.remove(inst) if this_inst: startup_event = this_inst.is_lost(ref_time=nowtime) - if this_inst.capacity == 0: + if this_inst.capacity == 0 and this_inst.enabled: logger.warning('Rejoining the cluster as instance {}.'.format(this_inst.hostname)) - this_inst.capacity = get_system_task_capacity() - this_inst.version = awx_application_version - this_inst.save(update_fields=['capacity', 'version', 'modified']) + if this_inst.enabled: + this_inst.refresh_capacity() + handle_ha_toplogy_changes.apply_async() + elif this_inst.capacity != 0 and not this_inst.enabled: + this_inst.capacity = 0 + this_inst.save(update_fields=['capacity']) + handle_ha_toplogy_changes.apply_async() if startup_event: return else: @@ -329,7 +332,7 @@ def cluster_node_heartbeat(self): for other_inst in instance_list: if other_inst.version == "": continue - if Version(other_inst.version.split('-', 1)[0]) > Version(awx_application_version) and not settings.DEBUG: + if Version(other_inst.version.split('-', 1)[0]) > Version(awx_application_version.split('-', 1)[0]) and not settings.DEBUG: logger.error("Host {} reports version {}, but this node {} is at {}, shutting down".format(other_inst.hostname, other_inst.version, this_inst.hostname, diff --git a/awx/main/tests/functional/test_jobs.py b/awx/main/tests/functional/test_jobs.py index e9504d1232..aa95574b36 100644 --- a/awx/main/tests/functional/test_jobs.py +++ b/awx/main/tests/functional/test_jobs.py @@ -1,9 +1,11 @@ -from awx.main.models import Job, Instance -from django.test.utils import override_settings import pytest - +import mock import json +from awx.main.models import Job, Instance +from awx.main.tasks import cluster_node_heartbeat +from django.test.utils import override_settings + @pytest.mark.django_db def test_orphan_unified_job_creation(instance, inventory): @@ -17,13 +19,19 @@ def test_orphan_unified_job_creation(instance, inventory): @pytest.mark.django_db +@mock.patch('awx.main.utils.common.get_cpu_capacity', lambda: (2,8)) +@mock.patch('awx.main.utils.common.get_mem_capacity', lambda: (8000,62)) +@mock.patch('awx.main.tasks.handle_ha_toplogy_changes.apply_async', lambda: True) def test_job_capacity_and_with_inactive_node(): - Instance.objects.create(hostname='test-1', capacity=50) - assert Instance.objects.total_capacity() == 50 - Instance.objects.create(hostname='test-2', capacity=50) - assert Instance.objects.total_capacity() == 100 - with override_settings(AWX_ACTIVE_NODE_TIME=0): - assert Instance.objects.total_capacity() < 100 + i = Instance.objects.create(hostname='test-1') + i.refresh_capacity() + assert i.capacity == 62 + i.enabled = False + i.save() + with override_settings(CLUSTER_HOST_ID=i.hostname): + cluster_node_heartbeat() + i = Instance.objects.get(id=i.id) + assert i.capacity == 0 @pytest.mark.django_db diff --git a/awx/main/tests/unit/utils/test_ha.py b/awx/main/tests/unit/utils/test_ha.py index b71ca454e1..3dd9adfc35 100644 --- a/awx/main/tests/unit/utils/test_ha.py +++ b/awx/main/tests/unit/utils/test_ha.py @@ -60,6 +60,7 @@ 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/utils/common.py b/awx/main/utils/common.py index 443da1f1f3..f2ea63c4ed 100644 --- a/awx/main/utils/common.py +++ b/awx/main/utils/common.py @@ -20,6 +20,8 @@ import six import psutil from StringIO import StringIO +from decimal import Decimal + # Decorator from decorator import decorator @@ -45,7 +47,7 @@ __all__ = ['get_object_or_400', 'get_object_or_403', 'camelcase_to_underscore', 'ignore_inventory_computed_fields', 'ignore_inventory_group_removal', '_inventory_updates', 'get_pk_from_dict', 'getattrd', 'NoDefaultProvided', 'get_current_apps', 'set_current_apps', 'OutputEventFilter', - 'extract_ansible_vars', 'get_search_fields', 'get_system_task_capacity', + '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', 'has_model_field_prefetched', 'set_environ', 'IllegalArgumentError', 'get_custom_venv_choices'] @@ -632,19 +634,52 @@ def parse_yaml_or_json(vars_str, silent_failure=True): return vars_dict -@memoize() -def get_system_task_capacity(): +def get_cpu_capacity(): + from django.conf import settings + settings_forkcpu = getattr(settings, 'SYSTEM_TASK_FORKS_CPU', None) + env_forkcpu = os.getenv('SYSTEM_TASK_FORKS_CPU', None) + cpu = psutil.cpu_count() + + if env_forkcpu: + forkcpu = int(env_forkcpu) + elif settings_forkcpu: + forkcpu = int(settings_forkcpu) + else: + forkcpu = 4 + return (cpu, cpu * forkcpu) + + +def get_mem_capacity(): + from django.conf import settings + settings_forkmem = getattr(settings, 'SYSTEM_TASK_FORKS_MEM', None) + env_forkmem = os.getenv('SYSTEM_TASK_FORKS_MEM', None) + if env_forkmem: + forkmem = int(env_forkmem) + elif settings_forkmem: + forkmem = int(settings_forkmem) + else: + forkmem = 100 + + mem = psutil.virtual_memory().total + return (mem, max(1, ((mem / 1024 / 1024) - 2048) / forkmem)) + + +def get_system_task_capacity(scale=Decimal(1.0)): ''' Measure system memory and use it as a baseline for determining the system's capacity ''' from django.conf import settings - if hasattr(settings, 'SYSTEM_TASK_CAPACITY'): - return settings.SYSTEM_TASK_CAPACITY - mem = psutil.virtual_memory() - total_mem_value = mem.total / 1024 / 1024 - if total_mem_value <= 2048: - return 50 - return 50 + ((total_mem_value / 1024) - 2) * 75 + settings_forks = getattr(settings, 'SYSTEM_TASK_FORKS_CAPACITY', None) + env_forks = os.getenv('SYSTEM_TASK_FORKS_CAPACITY', None) + + if env_forks: + return int(env_forks) + elif settings_forks: + return int(settings_forks) + + _, cpu_cap = get_cpu_capacity() + _, mem_cap = get_mem_capacity() + return min(mem_cap, cpu_cap) + ((max(mem_cap, cpu_cap) - min(mem_cap, cpu_cap)) * scale) _inventory_updates = threading.local() diff --git a/awx/main/utils/ha.py b/awx/main/utils/ha.py index 376faf9eb9..bb3a0a73cc 100644 --- a/awx/main/utils/ha.py +++ b/awx/main/utils/ha.py @@ -24,7 +24,7 @@ def _add_remove_celery_worker_queues(app, instance, worker_queues, worker_name): queue['alias'] in settings.AWX_CELERY_QUEUES_STATIC: continue - if queue['name'] not in ig_names | set([instance.hostname]): + if queue['name'] not in ig_names | set([instance.hostname]) or not instance.enabled: app.control.cancel_consumer(queue['name'], reply=True, destination=[worker_name]) removed_queues.append(queue['name']) @@ -43,7 +43,6 @@ def update_celery_worker_routes(instance, conf): 'awx.main.tasks.purge_old_stdout_files', ] routes_updated = {} - # Instance is, effectively, a controller node if instance.is_controller(): tasks.append('awx.main.tasks.awx_isolated_heartbeat') diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 5fb4edfe69..a1223c5ed1 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -639,9 +639,6 @@ AWX_PROOT_BASE_PATH = "/tmp" # Note: This setting may be overridden by database settings. AWX_ANSIBLE_CALLBACK_PLUGINS = "" -# Time at which an HA node is considered active -AWX_ACTIVE_NODE_TIME = 7200 - # Automatically remove nodes that have missed their heartbeats after some time AWX_AUTO_DEPROVISION_INSTANCES = False diff --git a/docs/clustering.md b/docs/clustering.md index df52cbadd8..9ab61bba56 100644 --- a/docs/clustering.md +++ b/docs/clustering.md @@ -28,6 +28,8 @@ It's important to point out a few existing things: * Existing old-style HA deployments will be transitioned automatically to the new HA system during the upgrade process to 3.1. * Manual projects will need to be synced to all instances by the customer +Ansible Tower 3.3 adds support for container-based clusters using Openshift or Kubernetes + ## Important Changes * There is no concept of primary/secondary in the new Tower system. *All* systems are primary. @@ -226,6 +228,47 @@ show up in api endpoints and stats monitoring. These groups can be removed with $ awx-manage unregister_queue --queuename= ``` +### Configuring Instances and Instance Groups from the API + +Instance Groups can be created by posting to `/api/v2/instance_groups` as a System Admin. + +Once created, `Instances` can be associated with an Instance Group with: + +``` +HTTP POST /api/v2/instance_groups/x/instances/ {'id': y}` +``` + +An `Instance` that is added to an `InstanceGroup` will automatically reconfigure itself to listen on the group's work queue. See the following +section `Instance Group Policies` for more details. + +### Instance Group Policies + +Tower `Instances` can be configured to automatically join `Instance Groups` when they come online by defining a policy. These policies are evaluated for +every new Instance that comes online. + +Instance Group Policies are controlled by 3 optional fields on an `Instance Group`: + +* `policy_instance_percentage`: This is a number between 0 - 100. It gaurantees that this percentage of active Tower instances will be added + to this `Instance Group`. As new instances come online, if the number of Instances in this group relative to the total number of instances + is less than the given percentage then new ones will be added until the percentage condition is satisfied. +* `policy_instance_minimum`: This policy attempts to keep at least this many `Instances` in the `Instance Group`. If the number of + available instances is lower than this minimum then all `Instances` will be placed in this `Instance Group`. +* `policy_instance_list`: This is a fixed list of `Instance` names. These `Instances` will *always* be added to this `Instance Group`. + Further, by adding Instances to this list you are declaring that you will manually manage those Instances and they will not be eligible under any other + policy. This means they will not be automatically added to any other `Instance Group` even if the policy would cause them to be matched. + +> NOTES + +* `Instances` that are assigned directly to `Instance Groups` by posting to `/api/v2/instance_groups/x/instances` or + `/api/v2/instances/x/instance_groups` are automatically added to the `policy_instance_list`. This means they are subject to the + normal caveats for `policy_instance_list` and must be manually managed. +* `policy_instance_percentage` and `policy_instance_minimum` work together. For example, if you have a `policy_instance_percentage` of + 50% and a `policy_instance_minimum` of 2 and you start 6 `Instances`. 3 of them would be assigned to the `Instance Group`. If you reduce the number + of `Instances` to 2 then both of them would be assigned to the `Instance Group` to satisfy `policy_instance_minimum`. In this way, you can set a lower + bound on the amount of available resources. +* Policies don't actively prevent `Instances` from being associated with multiple `Instance Groups` but this can effectively be achieved by making the percentages + sum to 100. If you have 4 `Instance Groups` assign each a percentage value of 25 and the `Instances` will be distributed among them with no overlap. + ### Status and Monitoring Tower itself reports as much status as it can via the api at `/api/v2/ping` in order to provide validation of the health From 368101812cfa7c21d8611faf67f0a92ba3f92be1 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Wed, 10 Jan 2018 12:59:53 -0500 Subject: [PATCH 34/82] Add Instance and InstanceGroup models --- .../lib/components/components.strings.js | 4 +- awx/ui/client/lib/components/index.js | 2 + .../client/lib/components/input/_index.less | 38 +- .../lib/components/input/lookup.directive.js | 10 + .../lib/components/input/lookup.partial.html | 64 ++-- .../lib/components/input/slider.directive.js | 38 ++ .../lib/components/input/slider.partial.html | 13 + awx/ui/client/lib/components/list/_index.less | 38 ++ .../lib/components/list/row-item.directive.js | 3 + .../lib/components/list/row-item.partial.html | 12 +- .../lib/components/tabs/tab.partial.html | 1 + awx/ui/client/lib/models/Base.js | 8 +- awx/ui/client/lib/models/Instance.js | 47 +++ awx/ui/client/lib/models/InstanceGroup.js | 47 +++ awx/ui/client/lib/models/Job.js | 21 ++ awx/ui/client/lib/models/index.js | 4 + awx/ui/client/lib/theme/_global.less | 15 +- awx/ui/client/lib/theme/_mixins.less | 19 + awx/ui/client/lib/theme/_variables.less | 2 + awx/ui/client/lib/theme/index.less | 1 + awx/ui/client/src/app.js | 17 +- .../add-edit-instance-groups.view.html | 34 ++ .../add-edit/add-instance-group.controller.js | 44 +++ .../add-instance-list-policy.controller.js | 40 ++ .../add-instance-list-policy.partial.html | 53 +++ .../edit-instance-group.controller.js | 55 +++ .../capacity-bar/capacity-bar.block.less | 40 +- .../capacity-bar/capacity-bar.directive.js | 79 ++-- .../capacity-bar/capacity-bar.partial.html | 15 +- .../instance-groups.partial.html | 8 +- .../instance-groups/instance-groups.route.js | 41 -- .../instance-groups.strings.js | 25 ++ .../instance-jobs/instance-jobs-list.route.js | 41 -- .../instance-jobs/instance-jobs.controller.js | 151 ++++---- .../instance-jobs/instance-jobs.list.js | 78 ---- .../instance-jobs/instance-jobs.partial.html | 32 -- .../instance-jobs/instance-jobs.route.js | 38 -- .../instances/instance-modal.block.less | 24 ++ .../instances/instance-modal.controller.js | 74 ++++ .../instances/instance-modal.partial.html | 55 +++ .../instances/instances-list.partial.html | 104 ++--- .../instances/instances-list.route.js | 35 -- .../instances/instances.controller.js | 67 +++- .../instances/instances.list.js | 29 -- .../instances/instances.route.js | 35 -- .../instance-groups/jobs/jobs-list.route.js | 41 -- .../instance-groups/jobs/jobs.controller.js | 161 ++++---- .../src/instance-groups/jobs/jobs.list.js | 150 ++++---- .../src/instance-groups/jobs/jobs.strings.js | 30 ++ .../src/instance-groups/jobs/list.view.html | 103 +++++ .../list/instance-groups-list.controller.js | 36 +- .../list/instance-groups-list.partial.html | 133 ++++--- awx/ui/client/src/instance-groups/main.js | 355 ++++++++++++++++-- .../multi-select-preview.partial.html | 1 + 54 files changed, 1759 insertions(+), 852 deletions(-) create mode 100644 awx/ui/client/lib/components/input/slider.directive.js create mode 100644 awx/ui/client/lib/components/input/slider.partial.html create mode 100644 awx/ui/client/lib/models/Instance.js create mode 100644 awx/ui/client/lib/models/InstanceGroup.js create mode 100644 awx/ui/client/lib/models/Job.js create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js create mode 100644 awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html create mode 100644 awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js delete mode 100644 awx/ui/client/src/instance-groups/instance-groups.route.js create mode 100644 awx/ui/client/src/instance-groups/instance-groups.strings.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html delete mode 100644 awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js create mode 100644 awx/ui/client/src/instance-groups/instances/instance-modal.block.less create mode 100644 awx/ui/client/src/instance-groups/instances/instance-modal.controller.js create mode 100644 awx/ui/client/src/instance-groups/instances/instance-modal.partial.html delete mode 100644 awx/ui/client/src/instance-groups/instances/instances-list.route.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instances.list.js delete mode 100644 awx/ui/client/src/instance-groups/instances/instances.route.js delete mode 100644 awx/ui/client/src/instance-groups/jobs/jobs-list.route.js create mode 100644 awx/ui/client/src/instance-groups/jobs/jobs.strings.js create mode 100644 awx/ui/client/src/instance-groups/jobs/list.view.html diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index 93f5ab1416..ddc765533b 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -71,6 +71,7 @@ function ComponentsStrings (BaseString) { INVENTORY_SCRIPTS: t.s('Inventory Scripts'), NOTIFICATIONS: t.s('Notifications'), MANAGEMENT_JOBS: t.s('Management Jobs'), + INSTANCES: t.s('Instances'), INSTANCE_GROUPS: t.s('Instance Groups'), SETTINGS: t.s('Settings'), FOOTER_ABOUT: t.s('About'), @@ -78,7 +79,8 @@ function ComponentsStrings (BaseString) { }; ns.capacityBar = { - IS_OFFLINE: t.s('Unavailable to run jobs.') + IS_OFFLINE: t.s('Unavailable to run jobs.'), + IS_OFFLINE_LABEL: t.s('Unavailable') }; ns.relaunch = { diff --git a/awx/ui/client/lib/components/index.js b/awx/ui/client/lib/components/index.js index 6f15cae762..d33e79329a 100644 --- a/awx/ui/client/lib/components/index.js +++ b/awx/ui/client/lib/components/index.js @@ -12,6 +12,7 @@ import inputLookup from '~components/input/lookup.directive'; import inputMessage from '~components/input/message.directive'; import inputSecret from '~components/input/secret.directive'; import inputSelect from '~components/input/select.directive'; +import inputSlider from '~components/input/slider.directive'; import inputText from '~components/input/text.directive'; import inputTextarea from '~components/input/textarea.directive'; import inputTextareaSecret from '~components/input/textarea-secret.directive'; @@ -54,6 +55,7 @@ angular .directive('atInputMessage', inputMessage) .directive('atInputSecret', inputSecret) .directive('atInputSelect', inputSelect) + .directive('atInputSlider', inputSlider) .directive('atInputText', inputText) .directive('atInputTextarea', inputTextarea) .directive('atInputTextareaSecret', inputTextareaSecret) diff --git a/awx/ui/client/lib/components/input/_index.less b/awx/ui/client/lib/components/input/_index.less index b92692c34e..03589273eb 100644 --- a/awx/ui/client/lib/components/input/_index.less +++ b/awx/ui/client/lib/components/input/_index.less @@ -163,7 +163,7 @@ } .at-InputMessage--rejected { - font-size: @at-font-size-help-text; + font-size: @at-font-size-help-text; color: @at-color-error; margin: @at-margin-input-message 0 0 0; padding: 0; @@ -182,7 +182,7 @@ & > i { font-size: @at-font-size-button; - position: absolute; + position: absolute; z-index: 3; pointer-events: none; top: @at-height-input / 3; @@ -218,3 +218,37 @@ min-height: @at-height-textarea; padding: 6px @at-padding-input 0 @at-padding-input; } + +.at-InputSlider { + display: flex; + padding: 5px 0; + + p { + color: @at-color-form-label; + font-size: @at-font-size-help-text; + font-weight: @at-font-weight-body; + margin: 0 0 0 10px; + padding: 0; + width: 50px; + } + + input[type=range] { + -webkit-appearance: none; + width: 100%; + background: transparent; + height: 20px; + border-right: 1px solid @at-color-input-slider-track; + border-left: 1px solid @at-color-input-slider-track; + + &:focus { + outline: none; + } + + &::-webkit-slider-runnable-track { + .at-mixin-sliderTrack(); + } + &::-webkit-slider-thumb { + .at-mixin-sliderThumb(); + } + } +} \ No newline at end of file diff --git a/awx/ui/client/lib/components/input/lookup.directive.js b/awx/ui/client/lib/components/input/lookup.directive.js index 1e6cc13588..fcee7ad72c 100644 --- a/awx/ui/client/lib/components/input/lookup.directive.js +++ b/awx/ui/client/lib/components/input/lookup.directive.js @@ -118,6 +118,16 @@ function AtInputLookupController (baseInputController, $q, $state) { vm.searchAfterDebounce(); }; + + vm.removeTag = (i) => { + let list; + if (!i.id) { + list = _.remove(scope.state._value, i); + } else { + list = _.remove(scope.state._value, i.id); + } + scope.state._value = list; + }; } AtInputLookupController.$inject = [ diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html index 21ebf03b5d..271c24212f 100644 --- a/awx/ui/client/lib/components/input/lookup.partial.html +++ b/awx/ui/client/lib/components/input/lookup.partial.html @@ -1,27 +1,45 @@
-
- +
+ -
- - - - +
+ + + + + + + + + + +
+
+ +
+
+ {{ tag.hostname }} + {{ tag }} +
+
+
+
+ +
- -
- -
-
+
+
\ No newline at end of file diff --git a/awx/ui/client/lib/components/input/slider.directive.js b/awx/ui/client/lib/components/input/slider.directive.js new file mode 100644 index 0000000000..a2e1b8c28e --- /dev/null +++ b/awx/ui/client/lib/components/input/slider.directive.js @@ -0,0 +1,38 @@ +const templateUrl = require('~components/input/slider.partial.html'); + +function atInputSliderLink (scope, element, attrs, controllers) { + const [formController, inputController] = controllers; + + inputController.init(scope, element, formController); +} + +function atInputSliderController (baseInputController) { + const vm = this || {}; + + vm.init = (_scope_, _element_, form) => { + baseInputController.call(vm, 'input', _scope_, _element_, form); + + vm.check(); + }; +} + +atInputSliderController.$inject = ['BaseInputController']; + +function atInputSlider () { + return { + restrict: 'E', + require: ['^^atForm', 'atInputSlider'], + replace: true, + templateUrl, + controller: atInputSliderController, + controllerAs: 'vm', + link: atInputSliderLink, + scope: { + state: '=?', + col: '@', + tab: '@' + } + }; +} + +export default atInputSlider; diff --git a/awx/ui/client/lib/components/input/slider.partial.html b/awx/ui/client/lib/components/input/slider.partial.html new file mode 100644 index 0000000000..e4649149f8 --- /dev/null +++ b/awx/ui/client/lib/components/input/slider.partial.html @@ -0,0 +1,13 @@ +
+
+ +
+ +

{{ state._value }}%

+
+
+
diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index cd1fa9a023..77997bbbcc 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -86,12 +86,26 @@ border-top: @at-border-default-width solid @at-color-list-border; } +.at-Row--rowLayout { + display: flex; + flex-direction: row; + + .at-RowItem { + margin-right: @at-space-4x; + + &-label { + width: auto; + } + } +} + .at-Row-actions { display: flex; } .at-Row-items { align-self: flex-start; + flex: 1; } .at-RowItem { @@ -101,6 +115,7 @@ } .at-RowItem--isHeader { + color: @at-color-body-text; margin-bottom: @at-margin-bottom-list-header; line-height: @at-line-height-list-row-item-header; } @@ -146,8 +161,26 @@ .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; +} + +.at-RowItem-value { + font-size: @at-font-size-3x; +} + +.at-RowItem-badge { + background-color: @at-gray-848992; + border-radius: @at-border-radius; + color: @at-white; + font-size: 11px; + font-weight: normal; + height: 14px; + line-height: 10px; + margin: 0 10px; + padding: 2px 10px; } .at-RowAction { @@ -180,6 +213,11 @@ background-color: @at-color-list-row-action-hover-danger; } +.at-Row .at-Row-checkbox { + align-self: start; + margin: 2px 20px 0 0; +} + @media screen and (max-width: @at-breakpoint-compact-list) { .at-Row-actions { flex-direction: column; 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 972008f7a2..e07820468e 100644 --- a/awx/ui/client/lib/components/list/row-item.directive.js +++ b/awx/ui/client/lib/components/list/row-item.directive.js @@ -7,10 +7,13 @@ function atRowItem () { transclude: true, templateUrl, scope: { + badge: '@', headerValue: '@', headerLink: '@', headerTag: '@', labelValue: '@', + labelLink: '@', + labelState: '@', value: '@', valueLink: '@', smartStatus: '=?', 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 a9b81ae20c..ca58947b79 100644 --- a/awx/ui/client/lib/components/list/row-item.partial.html +++ b/awx/ui/client/lib/components/list/row-item.partial.html @@ -9,13 +9,19 @@
{{ headerTag }}
-
+ +
{{ labelValue }}
+ -
-
+
\ No newline at end of file diff --git a/awx/ui/client/lib/components/tabs/tab.partial.html b/awx/ui/client/lib/components/tabs/tab.partial.html index 263a5d1d96..747e470571 100644 --- a/awx/ui/client/lib/components/tabs/tab.partial.html +++ b/awx/ui/client/lib/components/tabs/tab.partial.html @@ -1,6 +1,7 @@ diff --git a/awx/ui/client/lib/models/Base.js b/awx/ui/client/lib/models/Base.js index 8845c24f82..7fafb05c75 100644 --- a/awx/ui/client/lib/models/Base.js +++ b/awx/ui/client/lib/models/Base.js @@ -129,6 +129,10 @@ function httpPost (config = {}) { data: config.data }; + if (config.url) { + req.url = `${this.path}${config.url}`; + } + return $http(req) .then(res => { this.model.GET = res.data; @@ -323,7 +327,7 @@ function has (method, keys) { return value !== undefined && value !== null; } -function extend (method, related) { +function extend (method, related, config = {}) { if (!related) { related = method; method = 'GET'; @@ -337,6 +341,8 @@ function extend (method, related) { url: this.get(`related.${related}`) }; + Object.assign(req, config); + return $http(req) .then(({ data }) => { this.set(method, `related.${related}`, data); diff --git a/awx/ui/client/lib/models/Instance.js b/awx/ui/client/lib/models/Instance.js new file mode 100644 index 0000000000..09b7df0547 --- /dev/null +++ b/awx/ui/client/lib/models/Instance.js @@ -0,0 +1,47 @@ +let Base; + +function createFormSchema (method, config) { + if (!config) { + config = method; + method = 'GET'; + } + + const schema = Object.assign({}, this.options(`actions.${method.toUpperCase()}`)); + + if (config && config.omit) { + config.omit.forEach(key => delete schema[key]); + } + + Object.keys(schema).forEach(key => { + schema[key].id = key; + + if (this.has(key)) { + schema[key]._value = this.get(key); + } + }); + + return schema; +} + +function InstanceModel (method, resource, config) { + // Base takes two args: resource and settings + // resource is the string endpoint + Base.call(this, 'instances'); + + this.Constructor = InstanceModel; + this.createFormSchema = createFormSchema.bind(this); + + return this.create(method, resource, config); +} + +function InstanceModelLoader (BaseModel) { + Base = BaseModel; + + return InstanceModel; +} + +InstanceModelLoader.$inject = [ + 'BaseModel' +]; + +export default InstanceModelLoader; diff --git a/awx/ui/client/lib/models/InstanceGroup.js b/awx/ui/client/lib/models/InstanceGroup.js new file mode 100644 index 0000000000..cc82432c42 --- /dev/null +++ b/awx/ui/client/lib/models/InstanceGroup.js @@ -0,0 +1,47 @@ +let Base; + +function createFormSchema (method, config) { + if (!config) { + config = method; + method = 'GET'; + } + + const schema = Object.assign({}, this.options(`actions.${method.toUpperCase()}`)); + + if (config && config.omit) { + config.omit.forEach(key => delete schema[key]); + } + + Object.keys(schema).forEach(key => { + schema[key].id = key; + + if (this.has(key)) { + schema[key]._value = this.get(key); + } + }); + + return schema; +} + +function InstanceGroupModel (method, resource, config) { + // Base takes two args: resource and settings + // resource is the string endpoint + Base.call(this, 'instance_groups'); + + this.Constructor = InstanceGroupModel; + this.createFormSchema = createFormSchema.bind(this); + + return this.create(method, resource, config); +} + +function InstanceGroupModelLoader (BaseModel) { + Base = BaseModel; + + return InstanceGroupModel; +} + +InstanceGroupModelLoader.$inject = [ + 'BaseModel' +]; + +export default InstanceGroupModelLoader; diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js new file mode 100644 index 0000000000..9be420b2f9 --- /dev/null +++ b/awx/ui/client/lib/models/Job.js @@ -0,0 +1,21 @@ +let Base; + +function JobModel (method, resource, config) { + Base.call(this, 'jobs'); + + this.Constructor = JobModel; + + return this.create(method, resource, config); +} + +function JobModelLoader (BaseModel) { + Base = BaseModel; + + return JobModel; +} + +JobModelLoader.$inject = [ + 'BaseModel' +]; + +export default JobModelLoader; diff --git a/awx/ui/client/lib/models/index.js b/awx/ui/client/lib/models/index.js index 6dbea4c954..3c43af7915 100644 --- a/awx/ui/client/lib/models/index.js +++ b/awx/ui/client/lib/models/index.js @@ -9,6 +9,8 @@ import Organization from '~models/Organization'; import Project from '~models/Project'; import JobTemplate from '~models/JobTemplate'; import WorkflowJobTemplateNode from '~models/WorkflowJobTemplateNode'; +import Instance from '~models/Instance'; +import InstanceGroup from '~models/InstanceGroup'; import InventorySource from '~models/InventorySource'; import Inventory from '~models/Inventory'; import InventoryScript from '~models/InventoryScript'; @@ -32,6 +34,8 @@ angular .service('ProjectModel', Project) .service('JobTemplateModel', JobTemplate) .service('WorkflowJobTemplateNodeModel', WorkflowJobTemplateNode) + .service('InstanceModel', Instance) + .service('InstanceGroupModel', InstanceGroup) .service('InventorySourceModel', InventorySource) .service('InventoryModel', Inventory) .service('InventoryScriptModel', InventoryScript) diff --git a/awx/ui/client/lib/theme/_global.less b/awx/ui/client/lib/theme/_global.less index b1e46927ef..b62f501c33 100644 --- a/awx/ui/client/lib/theme/_global.less +++ b/awx/ui/client/lib/theme/_global.less @@ -15,7 +15,16 @@ background: @at-color-disabled; } } - + +.at-Button--add { + &:extend(.at-Button--success all); + &:before { + content: "+"; + font-size: 20px; + } + border-color: transparent; +} + .at-Button--info { .at-mixin-Button(); .at-mixin-ButtonColor('at-color-info', 'at-color-default'); @@ -26,7 +35,7 @@ .at-mixin-ButtonColor('at-color-error', 'at-color-default'); } -.at-ButtonHollow--default { +.at-ButtonHollow--default { .at-mixin-Button(); .at-mixin-ButtonHollow( 'at-color-default', @@ -41,5 +50,5 @@ } .at-Button--expand { - width: 100%; + width: 100%; } diff --git a/awx/ui/client/lib/theme/_mixins.less b/awx/ui/client/lib/theme/_mixins.less index 6dc36a7b24..d40373c836 100644 --- a/awx/ui/client/lib/theme/_mixins.less +++ b/awx/ui/client/lib/theme/_mixins.less @@ -21,6 +21,7 @@ } .at-mixin-Button () { + border-radius: @at-border-radius; height: @at-height-input; padding: @at-padding-button-vertical @at-padding-button-horizontal; font-size: @at-font-size-body; @@ -102,3 +103,21 @@ .at-mixin-FontFixedWidth () { font-family: Menlo, Monaco, Consolas, "Courier New", monospace; } + +.at-mixin-sliderTrack() { + background: @at-color-input-slider-track; + cursor: pointer; + height: 1px; + width: 100%; +} + +.at-mixin-sliderThumb() { + -webkit-appearance: none; + background: @at-color-input-slider-thumb; + border-radius: 50%; + border: none; + cursor: pointer; + height: 16px; + margin-top: -7px; + width: 16px; +} \ No newline at end of file diff --git a/awx/ui/client/lib/theme/_variables.less b/awx/ui/client/lib/theme/_variables.less index be5cde41ee..cd3a8a6675 100644 --- a/awx/ui/client/lib/theme/_variables.less +++ b/awx/ui/client/lib/theme/_variables.less @@ -147,6 +147,8 @@ @at-color-input-icon: @at-gray-b7; @at-color-input-placeholder: @at-gray-848992; @at-color-input-text: @at-gray-161b1f; +@at-color-input-slider-thumb: @at-blue; +@at-color-input-slider-track: @at-gray-b7; @at-color-icon-dismiss: @at-gray-d7; @at-color-icon-popover: @at-gray-848992; diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less index 1f0d3dd254..6b3f241ffd 100644 --- a/awx/ui/client/lib/theme/index.less +++ b/awx/ui/client/lib/theme/index.less @@ -73,6 +73,7 @@ @import '../../src/home/dashboard/dashboard.block.less'; @import '../../src/instance-groups/capacity-bar/capacity-bar.block.less'; @import '../../src/instance-groups/instance-group.block.less'; +@import '../../src/instance-groups/instances/instance-modal.block.less'; @import '../../src/inventories-hosts/inventories/insights/insights.block.less'; @import '../../src/inventories-hosts/inventories/list/host-summary-popover/host-summary-popover.block.less'; @import '../../src/inventories-hosts/inventories/related/hosts/related-groups-labels/relatedGroupsLabelsList.block.less'; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index e1667e6fb8..069ee2dd80 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -97,7 +97,6 @@ angular users.name, projects.name, scheduler.name, - instanceGroups.name, 'Utilities', 'templates', @@ -105,6 +104,7 @@ angular 'AWDirectives', 'features', + instanceGroups, atFeatures, atLibComponents, atLibModels, @@ -316,6 +316,21 @@ angular activateTab(); }); + $transitions.onCreate({}, function(trans) { + console.log('$onCreate ' +trans.to().name); + }); + + $transitions.onBefore({}, function(trans) { + console.log('$onBefore ' +trans.to().name); + }); + $transitions.onError({}, function(trans) { + + console.log('$onError ' +trans.to().name); + }); + $transitions.onExit({}, function(trans) { + console.log('$onExit ' +trans.to().name); + }); + $transitions.onSuccess({}, function(trans) { if(trans.to() === trans.from()) { diff --git a/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html new file mode 100644 index 0000000000..a980d74ec8 --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html @@ -0,0 +1,34 @@ + + + {{ vm.panelTitle }} + + + + {{:: vm.strings.get('tab.DETAILS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} + + + + + + + + + + + + + + + + +
+ + + + + +
+
+
\ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js new file mode 100644 index 0000000000..9fc838115b --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js @@ -0,0 +1,44 @@ +function AddController ($scope, $state, models, strings) { + const vm = this || {}; + + const { instanceGroup, instance } = models; + + vm.mode = 'add'; + vm.strings = strings; + vm.panelTitle = "New Instance Group"; + + vm.tab = { + details: { _active: true }, + instances: {_disabled: true }, + jobs: {_disabled: true } + }; + + vm.form = instanceGroup.createFormSchema('post'); + + vm.form.policy_instance_percentage._value = 0; + + vm.form.policy_instance_list._lookupTags = true; + vm.form.policy_instance_list._model = instance; + vm.form.policy_instance_list._placeholder = "Policy Instance List"; + vm.form.policy_instance_list._resource = 'instances'; + vm.form.policy_instance_list._route = 'instanceGroups.add.modal.instances'; + vm.form.policy_instance_list._value = []; + + vm.form.save = data => { + data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname); + return instanceGroup.request('post', { data }); + }; + + vm.form.onSaveSuccess = res => { + $state.go('instanceGroups.edit', { instance_group_id: res.data.id }, { reload: true }); + }; +} + +AddController.$inject = [ + '$scope', + '$state', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default AddController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js new file mode 100644 index 0000000000..c7819ba797 --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js @@ -0,0 +1,40 @@ +function InstanceModalController ($scope, $state, $http, $q, models, strings) { + const { instance } = models; + const vm = this || {}; + + vm.setInstances = () => { + vm.instances = instance.get('results').map(instance => { + instance.isSelected = false; + return instance; + }); + } + + init(); + + function init() { + vm.strings = strings; + vm.panelTitle = strings.get('instance.PANEL_TITLE'); + vm.setInstances(); + }; + + $scope.$watch('vm.instances', function() { + vm.selectedRows = _.filter(vm.instances, 'isSelected') + vm.deselectedRows = _.filter(vm.instances, 'isSelected', false); + }, true); + + vm.submit = () => { + $scope.$parent.$parent.$parent.state.policy_instance_list._value = vm.selectedRows; + $state.go("^.^"); + }; +} + +InstanceModalController.$inject = [ + '$scope', + '$state', + '$http', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default InstanceModalController; diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html new file mode 100644 index 0000000000..29493add34 --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html @@ -0,0 +1,53 @@ + \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js new file mode 100644 index 0000000000..49b197c6ec --- /dev/null +++ b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js @@ -0,0 +1,55 @@ +function EditController ($rootScope, $state, models, strings) { + const vm = this || {}; + + const { instanceGroup, instance } = models; + + $rootScope.breadcrumb.instance_group_name = instanceGroup.get('name'); + + vm.mode = 'edit'; + vm.strings = strings; + vm.panelTitle = instanceGroup.get('name'); + + vm.tab = { + details: { + _active: true, + _go: 'instanceGroups.edit', + _params: { instance_group_id: instanceGroup.get('id') } + }, + instances: { + _go: 'instanceGroups.instances', + _params: { instance_group_id: instanceGroup.get('id') } + }, + jobs: { + _go: 'instanceGroups.jobs', + _params: { instance_group_id: instanceGroup.get('id') } + } + }; + + vm.form = instanceGroup.createFormSchema('put'); + + vm.form.policy_instance_list._lookupTags = true; + vm.form.policy_instance_list._model = instance; + vm.form.policy_instance_list._placeholder = "Policy Instance List"; + vm.form.policy_instance_list._resource = 'instances'; + vm.form.policy_instance_list._route = 'instanceGroups.edit.modal.instances'; + vm.form.policy_instance_list._value = instanceGroup.get('policy_instance_list'); + + vm.form.save = data => { + instanceGroup.unset('policy_instance_list'); + data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname); + return instanceGroup.request('put', { data }); + }; + + vm.form.onSaveSuccess = res => { + $state.go('instanceGroups.edit', { instance_group_id: res.data.id }, { reload: true }); + }; +} + +EditController.$inject = [ + '$rootScope', + '$state', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default EditController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less index 1caba245f0..5668eef8ad 100644 --- a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less +++ b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.block.less @@ -1,21 +1,22 @@ capacity-bar { - - width: 50%; - margin-right: 25px; - min-width: 100px; - display: flex; align-items: center; + color: @at-gray-70; + display: flex; + font-size: @at-font-size; + min-width: 100px; + white-space: nowrap; .CapacityBar { background-color: @default-bg; - display: flex; - flex: 0 0 auto; - height: 10px; - border: 1px solid @default-link; - width: 100%; border-radius: 100vw; + border: 1px solid @default-link; + display: flex; + flex: 1; + height: 10px; + margin-right: @at-space-2x; + min-width: 100px; overflow: hidden; - margin-right: 10px; + width: 100%; } .CapacityBar-remaining { @@ -28,14 +29,21 @@ capacity-bar { } .CapacityBar--offline { - border-color: @d7grey; + color: @at-red; + border-color: @at-gray-a9; .CapacityBar-remaining { - background-color: @d7grey; + background-color: @at-gray-b7; } } - .Capacity-details--percentage { - color: @default-data-txt; + .Capacity-details--label { + margin-right: @at-space-2x; + text-align: right; + text-transform: uppercase; } -} + + .Capacity-details--percentage { + width: 40px; + } +} \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js index 5ea07d2dd3..7301d6e898 100644 --- a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js +++ b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.directive.js @@ -1,44 +1,47 @@ export default ['templateUrl', 'ComponentsStrings', - function (templateUrl, strings) { - return { - scope: { - capacity: '=', - totalCapacity: '=' - }, - templateUrl: templateUrl('instance-groups/capacity-bar/capacity-bar'), - restrict: 'E', - link: function(scope) { - scope.isOffline = false; +function (templateUrl, strings) { + return { + scope: { + capacity: '=', + totalCapacity: '=', + labelValue: '@', + badge: '=' + }, + templateUrl: templateUrl('instance-groups/capacity-bar/capacity-bar'), + restrict: 'E', + link: function(scope) { + scope.isOffline = false; - scope.$watch('totalCapacity', function(val) { - if (val === 0) { - scope.isOffline = true; - scope.offlineTip = strings.get(`capacityBar.IS_OFFLINE`); - } else { - scope.isOffline = false; - scope.offlineTip = null; - } - }, true); + scope.$watch('totalCapacity', function(val) { + if (val === 0) { + scope.isOffline = true; + scope.labelValue = strings.get(`capacityBar.IS_OFFLINE_LABEL`); + scope.offlineTip = strings.get(`capacityBar.IS_OFFLINE`); + } else { + scope.isOffline = false; + scope.offlineTip = null; + } + }, true); - scope.$watch('capacity', function() { - if (scope.totalCapacity !== 0) { - var percentageCapacity = Math - .round(scope.capacity / scope.totalCapacity * 1000) / 10; + scope.$watch('capacity', function() { + if (scope.totalCapacity !== 0) { + var percentageCapacity = Math + .round(scope.capacity / scope.totalCapacity * 1000) / 10; - scope.CapacityStyle = { - 'flex-grow': percentageCapacity * 0.01 - }; + scope.CapacityStyle = { + 'flex-grow': percentageCapacity * 0.01 + }; - scope.consumedCapacity = `${percentageCapacity}%`; - } else { - scope.CapacityStyle = { - 'flex-grow': 1 - }; + scope.consumedCapacity = `${percentageCapacity}%`; + } else { + scope.CapacityStyle = { + 'flex-grow': 1 + }; - scope.consumedCapacity = null; - } - }, true); - } - }; - } -]; + scope.consumedCapacity = null; + } + }, true); + } + }; +} +]; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html index d80ff84bc0..6c1fadb823 100644 --- a/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html +++ b/awx/ui/client/src/instance-groups/capacity-bar/capacity-bar.partial.html @@ -1,11 +1,20 @@ + + {{labelValue}} + +
-
-
+
+
-{{ consumedCapacity }} + + + {{ consumedCapacity }} + \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instance-groups.partial.html b/awx/ui/client/src/instance-groups/instance-groups.partial.html index baeaf59f00..217efd98d0 100644 --- a/awx/ui/client/src/instance-groups/instance-groups.partial.html +++ b/awx/ui/client/src/instance-groups/instance-groups.partial.html @@ -1,11 +1,13 @@
+
+
-
-
-
+
+ +
diff --git a/awx/ui/client/src/instance-groups/instance-groups.route.js b/awx/ui/client/src/instance-groups/instance-groups.route.js deleted file mode 100644 index c265b35cd9..0000000000 --- a/awx/ui/client/src/instance-groups/instance-groups.route.js +++ /dev/null @@ -1,41 +0,0 @@ -import {templateUrl} from '../shared/template-url/template-url.factory'; -import { N_ } from '../i18n'; - -export default { - name: 'instanceGroups', - url: '/instance_groups', - searchPrefix: 'instance_group', - ncyBreadcrumb: { - label: N_('INSTANCE GROUPS') - }, - params: { - instance_group_search: { - value: { - page_size: '20', - order_by: 'name' - }, - dynamic: true - } - }, - data: { - alwaysShowRefreshButton: true, - }, - views: { - '@': { - templateUrl: templateUrl('./instance-groups/instance-groups'), - }, - 'list@instanceGroups': { - templateUrl: templateUrl('./instance-groups/list/instance-groups-list'), - controller: 'InstanceGroupsList' - - } - }, - resolve: { - Dataset: ['InstanceGroupList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = GetBasePath(list.basePath) || GetBasePath(list.name); - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } -}; diff --git a/awx/ui/client/src/instance-groups/instance-groups.strings.js b/awx/ui/client/src/instance-groups/instance-groups.strings.js new file mode 100644 index 0000000000..a351fe0ca8 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instance-groups.strings.js @@ -0,0 +1,25 @@ +function InstanceGroupsStrings (BaseString) { + BaseString.call(this, 'instanceGroups'); + + const { t } = this; + const ns = this.instanceGroups; + + ns.state = { + ADD_BREADCRUMB_LABEL: t.s('CREATE INSTANCE GROUP'), + EDIT_BREADCRUMB_LABEL: t.s('EDIT INSTANCE GROUP') + }; + + ns.tab = { + DETAILS: t.s('DETAILS'), + INSTANCES: t.s('INSTANCES'), + JOBS: t.s('JOBS') + }; + + ns.instance = { + PANEL_TITLE: t.s('SELECT INSTANCE') + } +} + +InstanceGroupsStrings.$inject = ['BaseStringService']; + +export default InstanceGroupsStrings; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js deleted file mode 100644 index 1d82ca854e..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs-list.route.js +++ /dev/null @@ -1,41 +0,0 @@ -import { N_ } from '../../../i18n'; - -export default { - name: 'instanceGroups.instances.list.job.list', - url: '/jobs', - searchPrefix: 'instance_job', - ncyBreadcrumb: { - parent: 'instanceGroups.instances.list', - label: N_('{{ breadcrumb.instance_name }}') - }, - params: { - instance_job_search: { - value: { - page_size: '20', - order_by: '-finished', - not__launch_type: 'sync' - }, - dynamic: true - } - }, - views: { - 'list@instanceGroups.instances.list.job': { - templateProvider: function(InstanceJobsList, generateList) { - let html = generateList.build({ - list: InstanceJobsList - }); - return html; - }, - controller: 'InstanceJobsController' - } - }, - - resolve: { - Dataset: ['InstanceJobsList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = `${GetBasePath('instances')}${$stateParams.instance_id}/jobs`; - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ], - } -}; diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js index a7d50764f5..5fbce21d4b 100644 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js +++ b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.controller.js @@ -1,82 +1,81 @@ -export default ['$scope','InstanceJobsList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', - function($scope, InstanceJobsList, GetBasePath, Rest, Dataset, Find, $state, $q) { - let list = InstanceJobsList; +function InstanceJobsController ($scope, GetBasePath, Rest, Dataset, Find, $filter, $state, $q, model, strings, jobStrings) { + const vm = this || {}; + const { instance } = model; - init(); + init(); - function init(){ - $scope.optionsDefer = $q.defer(); - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - } + function init(){ + vm.strings = strings; + vm.jobStrings = jobStrings; + vm.queryset = { page_size: '10', order_by: '-finished'}; + vm.jobs = instance.get('related.jobs.results'); + vm.dataset = instance.get('related.jobs'); + vm.count = instance.get('related.jobs.count'); + vm.panelTitle = `${jobStrings.get('list.PANEL_TITLE')} | ${instance.get('hostname')}` - $scope.$on(`${list.iterator}_options`, function(event, data){ - $scope.options = data.data.actions.GET; - optionsRequestDataProcessing(); - }); - - // iterate over the list and add fields like type label, after the - // OPTIONS request returns, or the list is sorted/paginated/searched - function optionsRequestDataProcessing(){ - - if($scope[list.name] && $scope[list.name].length > 0) { - $scope[list.name].forEach(function(item, item_idx) { - var itm = $scope[list.name][item_idx]; - - if(item.summary_fields && item.summary_fields.source_workflow_job && - item.summary_fields.source_workflow_job.id){ - item.workflow_result_link = `/#/workflows/${item.summary_fields.source_workflow_job.id}`; - } - - // Set the item type label - if (list.fields.type && $scope.options && - $scope.options.hasOwnProperty('type')) { - $scope.options.type.choices.forEach(function(choice) { - if (choice[0] === item.type) { - itm.type_label = choice[1]; - } - }); - } - buildTooltips(itm); - }); - } - } - - function buildTooltips(job) { - job.status_tip = 'Job ' + job.status + ". Click for details."; - } - - $scope.viewjobResults = function(job) { - var goTojobResults = function(state) { - $state.go(state, { id: job.id }, { reload: true }); - }; - switch (job.type) { - case 'job': - goTojobResults('jobResult'); - break; - case 'ad_hoc_command': - goTojobResults('adHocJobStdout'); - break; - case 'system_job': - goTojobResults('managementJobStdout'); - break; - case 'project_update': - goTojobResults('scmUpdateStdout'); - break; - case 'inventory_update': - goTojobResults('inventorySyncStdout'); - break; - case 'workflow_job': - goTojobResults('workflowResults'); - break; - } + vm.tab = { + details: {_hide: true}, + instances: {_hide: true}, + jobs: {_hide: true} }; - - $scope.$watchCollection(`${$scope.list.name}`, function() { - optionsRequestDataProcessing(); - } - ); } -]; \ No newline at end of file + + vm.getTime = function(time) { + let val = ""; + if (time) { + val += $filter('longDate')(time); + } + if (val === "") { + val = undefined; + } + return val; + }; + + $scope.isSuccessful = function (status) { + return (status === "successful"); + }; + + $scope.viewjobResults = function(job) { + var goTojobResults = function(state) { + $state.go(state, { id: job.id }, { reload: true }); + }; + switch (job.type) { + case 'job': + goTojobResults('jobResult'); + break; + case 'ad_hoc_command': + goTojobResults('adHocJobStdout'); + break; + case 'system_job': + goTojobResults('managementJobStdout'); + break; + case 'project_update': + goTojobResults('scmUpdateStdout'); + break; + case 'inventory_update': + goTojobResults('inventorySyncStdout'); + break; + case 'workflow_job': + goTojobResults('workflowResults'); + break; + } + }; + +} + +InstanceJobsController.$inject = [ + '$scope', + 'GetBasePath', + 'Rest', + 'Dataset', + 'Find', + '$filter', + '$state', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings', + 'JobStrings' +]; + +export default InstanceJobsController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js deleted file mode 100644 index 58476f0054..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.list.js +++ /dev/null @@ -1,78 +0,0 @@ -export default ['i18n', function(i18n) { - return { - - name: 'instance_jobs', - iterator: 'instance_job', - index: false, - hover: false, - well: false, - emptyListText: i18n._('No jobs have yet run.'), - title: false, - basePath: 'api/v2/instances/{{$stateParams.instance_id}}/jobs', - - fields: { - status: { - label: '', - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus', - dataTipWatch: 'instance_job.status_tip', - awToolTip: "{{ instance_job.status_tip }}", - awTipPlacement: "right", - dataTitle: "{{ instance_job.status_popover_title }}", - icon: 'icon-job-{{ instance_job.status }}', - iconOnly: true, - ngClick:"viewjobResults(instance_job)", - nosort: true - }, - id: { - label: i18n._('ID'), - ngClick:"viewjobResults(instance_job)", - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumnAdjacent', - awToolTip: "{{ instance_job.status_tip }}", - dataPlacement: 'top', - noLink: true - }, - name: { - label: i18n._('Name'), - columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6', - ngClick: "viewjobResults(instance_job)", - nosort: true, - badgePlacement: 'right', - badgeCustom: true, - badgeIcon: ` - - W - - ` - }, - type: { - label: i18n._('Type'), - ngBind: 'instance_job.type_label', - link: false, - columnClass: "col-lg-2 hidden-md hidden-sm hidden-xs", - nosort: true - }, - finished: { - label: i18n._('Finished'), - noLink: true, - filter: "longDate", - columnClass: "col-lg-2 col-md-3 col-sm-3 hidden-xs", - key: true, - desc: true, - nosort: true - }, - labels: { - label: i18n._('Labels'), - type: 'labels', - nosort: true, - showDelete: false, - columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs', - sourceModel: 'labels', - sourceField: 'name', - }, - } - }; -}]; diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html deleted file mode 100644 index 9c40fe931f..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.partial.html +++ /dev/null @@ -1,32 +0,0 @@ -
-
-
-
-
-
{{ instanceName }}
-
-
-
-

Used Capacity

- -
-
-

Running Jobs

- - {{ instanceJobsRunning }} - -
-
-
- -
-
-
-
JOBS
-
-
-
-
-
diff --git a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js b/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js deleted file mode 100644 index 7e9be9a9de..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instance-jobs/instance-jobs.route.js +++ /dev/null @@ -1,38 +0,0 @@ -import { templateUrl } from '../../../shared/template-url/template-url.factory'; - -export default { - name: 'instanceGroups.instances.list.job', - url: '/:instance_id', - abstract: true, - ncyBreadcrumb: { - skip: true - }, - views: { - 'instanceJobs@instanceGroups': { - templateUrl: templateUrl('./instance-groups/instances/instance-jobs/instance-jobs'), - controller: function($scope, $rootScope, instance) { - $scope.instanceName = instance.hostname; - $scope.instanceCapacity = instance.consumed_capacity; - $scope.instanceTotalCapacity = instance.capacity; - $scope.instanceJobsRunning = instance.jobs_running; - $rootScope.breadcrumb.instance_name = instance.hostname; - } - } - }, - resolve: { - instance: ['GetBasePath', 'Rest', 'ProcessErrors', '$stateParams', function(GetBasePath, Rest, ProcessErrors, $stateParams) { - let url = GetBasePath('instances') + $stateParams.instance_id; - Rest.setUrl(url); - return Rest.get() - .then(({data}) => { - return data; - }) - .catch(({data, status}) => { - ProcessErrors(null, data, status, null, { - hdr: 'Error!', - msg: 'Failed to get instance groups info. GET returned status: ' + status - }); - }); - }] - } -}; diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.block.less b/awx/ui/client/src/instance-groups/instances/instance-modal.block.less new file mode 100644 index 0000000000..49ea9e10e7 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.block.less @@ -0,0 +1,24 @@ +.Modal-backdrop { + position: fixed; + top: 0px; + left: 0px; + height:100%; + width:100%; + background: #000; + z-index: 2; + opacity: 0.5; +} + +.Modal-holder { + position: fixed; + top: 1; + left: 0px; + right: 0px; + top: 0px; + bottom: 0px; + z-index: 3; + + .modal-dialog { + padding-top: 100px; + } +} diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js b/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js new file mode 100644 index 0000000000..dcb2c436d6 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.controller.js @@ -0,0 +1,74 @@ +function InstanceModalController ($scope, $state, $http, $q, models, strings) { + const { instance, instanceGroup } = models; + const vm = this || {}; + + vm.setInstances = () => { + vm.instances = instance.get('results').map(instance => { + instance.isSelected = false; + return instance; + }); + } + + vm.setRelatedInstances = () => { + vm.instanceGroupName = instanceGroup.get('name'); + vm.relatedInstances = instanceGroup.get('related.instances.results'); + vm.relatedInstanceIds = vm.relatedInstances.map(instance => instance.id); + vm.instances = instance.get('results').map(instance => { + instance.isSelected = vm.relatedInstanceIds.includes(instance.id); + return instance; + }); + } + + init(); + + function init() { + vm.strings = strings; + vm.panelTitle = strings.get('instance.PANEL_TITLE'); + vm.instanceGroupId = instanceGroup.get('id'); + + if (vm.instanceGroupId === undefined) { + vm.setInstances(); + } else { + vm.setRelatedInstances(); + } + }; + + $scope.$watch('vm.instances', function() { + vm.selectedRows = _.filter(vm.instances, 'isSelected') + vm.deselectedRows = _.filter(vm.instances, 'isSelected', false); + }, true); + + vm.submit = () => { + let associate = vm.selectedRows + .map(instance => ({id: instance.id})); + let disassociate = vm.deselectedRows + .map(instance => ({id: instance.id, disassociate: true})); + + let all = associate.concat(disassociate); + let defers = all.map((data) => { + let config = { + url: `${vm.instanceGroupId}/instances/`, + data: data + } + return instanceGroup.http.post(config); + }); + + Promise.all(defers) + .then(vm.onSaveSuccess); + }; + + vm.onSaveSuccess = () => { + $state.go('instanceGroups.instances', {}, {reload: 'instanceGroups.instances'}); + }; +} + +InstanceModalController.$inject = [ + '$scope', + '$state', + '$http', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings' +]; + +export default InstanceModalController; diff --git a/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html b/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html new file mode 100644 index 0000000000..0eb9187064 --- /dev/null +++ b/awx/ui/client/src/instance-groups/instances/instance-modal.partial.html @@ -0,0 +1,55 @@ + \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html index da8f052423..814588a101 100644 --- a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html +++ b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html @@ -1,44 +1,62 @@ -
- - + + + {{ vm.panelTitle }} + -
PLEASE ADD ITEMS TO THIS LIST
-
- - - - - - - - - - - - - - - - -
- "{{'Name' | translate}}" - - - Running Jobs - - Used Capacity -
- {{ instance.hostname }} - - - {{ instance.jobs_running }} - - - -
-
-
+ + {{:: vm.strings.get('tab.DETAILS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} + + + +
+ + + +
+ +
+
+
+ + + +
+ + +
+ + +
+
+ +
+ +
+
+
+
+ diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.route.js b/awx/ui/client/src/instance-groups/instances/instances-list.route.js deleted file mode 100644 index 16549d9d1e..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instances-list.route.js +++ /dev/null @@ -1,35 +0,0 @@ -import {templateUrl} from '../../shared/template-url/template-url.factory'; -import { N_ } from '../../i18n'; - -export default { - name: 'instanceGroups.instances.list', - url: '/instances', - searchPrefix: 'instance', - ncyBreadcrumb: { - parent: 'instanceGroups', - label: N_('{{breadcrumb.instance_group_name}}') - }, - params: { - instance_search: { - value: { - page_size: '20', - order_by: 'hostname' - }, - dynamic: true - } - }, - views: { - 'list@instanceGroups.instances': { - templateUrl: templateUrl('./instance-groups/instances/instances-list'), - controller: 'InstanceListController' - } - }, - resolve: { - Dataset: ['InstanceList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = `${GetBasePath('instance_groups')}${$stateParams.instance_group_id}/instances`; - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } -}; diff --git a/awx/ui/client/src/instance-groups/instances/instances.controller.js b/awx/ui/client/src/instance-groups/instances/instances.controller.js index 0481d84263..6e8de76864 100644 --- a/awx/ui/client/src/instance-groups/instances/instances.controller.js +++ b/awx/ui/client/src/instance-groups/instances/instances.controller.js @@ -1,20 +1,55 @@ -export default ['$scope', 'InstanceList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', - function($scope, InstanceList, GetBasePath, Rest, Dataset, Find, $state, $q) { - let list = InstanceList; +function InstancesController ($scope, $state, models, strings, Dataset) { + const { instanceGroup } = models; + const vm = this || {}; + vm.strings = strings; + vm.panelTitle = instanceGroup.get('name'); + vm.instances = instanceGroup.get('related.instances.results'); + vm.instance_group_id = instanceGroup.get('id'); - init(); + init(); - function init(){ - $scope.optionsDefer = $q.defer(); - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - } - - $scope.isActive = function(id) { - let selected = parseInt($state.params.instance_id); - return id === selected; + function init() { + $scope.list = { + iterator: 'instance', + name: 'instances' }; - + $scope.collection = { + basepath: 'instances', + iterator: 'instance' + }; + $scope[`${$scope.list.iterator}_dataset`] = Dataset.data; + $scope[$scope.list.name] = $scope[`${$scope.list.iterator}_dataset`].results; } -]; \ No newline at end of file + + vm.tab = { + details: { + _go: 'instanceGroups.edit', + _params: { instance_group_id: vm.instance_group_id } + }, + instances: { + _active: true, + _go: 'instanceGroups.instances', + _params: { instance_group_id: vm.instance_group_id } + }, + jobs: { + _go: 'instanceGroups.jobs', + _params: { instance_group_id: vm.instance_group_id } + } + }; + + + $scope.isActive = function(id) { + let selected = parseInt($state.params.instance_id); + return id === selected; + }; +} + +InstancesController.$inject = [ + '$scope', + '$state', + 'resolvedModels', + 'InstanceGroupsStrings', + 'Dataset' +]; + +export default InstancesController; diff --git a/awx/ui/client/src/instance-groups/instances/instances.list.js b/awx/ui/client/src/instance-groups/instances/instances.list.js deleted file mode 100644 index 048279d6c8..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instances.list.js +++ /dev/null @@ -1,29 +0,0 @@ -export default ['i18n', function(i18n) { - return { - name: 'instances' , - iterator: 'instance', - listTitle: false, - index: false, - hover: false, - tabs: true, - well: true, - - fields: { - hostname: { - key: true, - label: i18n._('Name'), - columnClass: 'col-md-3 col-sm-9 col-xs-9', - modalColumnClass: 'col-md-8', - uiSref: 'instanceGroups.instances.list.job({instance_id: instance.id})' - }, - consumed_capacity: { - label: i18n._('Capacity'), - nosort: true, - }, - jobs_running: { - label: i18n._('Running Jobs'), - nosort: true, - }, - } - }; -}]; diff --git a/awx/ui/client/src/instance-groups/instances/instances.route.js b/awx/ui/client/src/instance-groups/instances/instances.route.js deleted file mode 100644 index 8890171b58..0000000000 --- a/awx/ui/client/src/instance-groups/instances/instances.route.js +++ /dev/null @@ -1,35 +0,0 @@ -import {templateUrl} from '../../shared/template-url/template-url.factory'; - -export default { - name: 'instanceGroups.instances', - url: '/:instance_group_id', - abstract: true, - views: { - 'instances@instanceGroups': { - templateUrl: templateUrl('./instance-groups/instance-group'), - controller: function($scope, $rootScope, instanceGroup) { - $scope.instanceGroupName = instanceGroup.name; - $scope.instanceGroupCapacity = instanceGroup.consumed_capacity; - $scope.instanceGroupTotalCapacity = instanceGroup.capacity; - $scope.instanceGroupJobsRunning = instanceGroup.jobs_running; - $rootScope.breadcrumb.instance_group_name = instanceGroup.name; - } - } - }, - resolve: { - instanceGroup: ['GetBasePath', 'Rest', 'ProcessErrors', '$stateParams', function(GetBasePath, Rest, ProcessErrors, $stateParams) { - let url = GetBasePath('instance_groups') + $stateParams.instance_group_id; - Rest.setUrl(url); - return Rest.get() - .then(({data}) => { - return data; - }) - .catch(({data, status}) => { - ProcessErrors(null, data, status, null, { - hdr: 'Error!', - msg: 'Failed to get instance groups info. GET returned status: ' + status - }); - }); - }] - } -}; diff --git a/awx/ui/client/src/instance-groups/jobs/jobs-list.route.js b/awx/ui/client/src/instance-groups/jobs/jobs-list.route.js deleted file mode 100644 index 03854eca20..0000000000 --- a/awx/ui/client/src/instance-groups/jobs/jobs-list.route.js +++ /dev/null @@ -1,41 +0,0 @@ -import { N_ } from '../../i18n'; - -export default { - name: 'instanceGroups.instances.jobs', - url: '/jobs', - searchPrefix: 'job', - ncyBreadcrumb: { - parent: 'instanceGroups.instances.list', - label: N_('JOBS') - }, - params: { - job_search: { - value: { - page_size: '20', - order_by: '-finished', - not__launch_type: 'sync' - }, - dynamic: true - }, - instance_group_id: null - }, - views: { - 'list@instanceGroups.instances': { - templateProvider: function(JobsList, generateList) { - let html = generateList.build({ - list: JobsList - }); - return html; - }, - controller: 'JobsListController' - } - }, - resolve: { - Dataset: ['JobsList', 'QuerySet', '$stateParams', 'GetBasePath', - function(list, qs, $stateParams, GetBasePath) { - let path = `${GetBasePath('instance_groups')}${$stateParams.instance_group_id}/jobs`; - return qs.search(path, $stateParams[`${list.iterator}_search`]); - } - ] - } -}; diff --git a/awx/ui/client/src/instance-groups/jobs/jobs.controller.js b/awx/ui/client/src/instance-groups/jobs/jobs.controller.js index cfe2f73327..a6054805a0 100644 --- a/awx/ui/client/src/instance-groups/jobs/jobs.controller.js +++ b/awx/ui/client/src/instance-groups/jobs/jobs.controller.js @@ -1,82 +1,93 @@ -export default ['$scope','JobsList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', - function($scope, JobsList, GetBasePath, Rest, Dataset, Find, $state, $q) { - let list = JobsList; +function InstanceGroupJobsController ($scope, GetBasePath, Rest, Dataset, Find, $filter, $state, $q, model, strings, jobStrings) { + const vm = this || {}; + const { instanceGroup } = model; - init(); + init(); - function init(){ - $scope.optionsDefer = $q.defer(); - $scope.list = list; - $scope[`${list.iterator}_dataset`] = Dataset.data; - $scope[list.name] = $scope[`${list.iterator}_dataset`].results; - } + function init(){ + let instance_group_id = instanceGroup.get('id'); + vm.strings = strings; + vm.jobStrings = jobStrings; + vm.queryset = { page_size: '10', order_by: '-finished', instance_group_id: instance_group_id }; + vm.jobs = instanceGroup.get('related.jobs.results'); + vm.dataset = instanceGroup.get('related.jobs'); + vm.count = instanceGroup.get('related.jobs.count'); + vm.panelTitle = instanceGroup.get('name'); - $scope.$on(`${list.iterator}_options`, function(event, data){ - $scope.options = data.data.actions.GET; - optionsRequestDataProcessing(); - }); - - // iterate over the list and add fields like type label, after the - // OPTIONS request returns, or the list is sorted/paginated/searched - function optionsRequestDataProcessing(){ - - if($scope[list.name] && $scope[list.name].length > 0) { - $scope[list.name].forEach(function(item, item_idx) { - var itm = $scope[list.name][item_idx]; - if(item.summary_fields && item.summary_fields.source_workflow_job && - item.summary_fields.source_workflow_job.id){ - item.workflow_result_link = `/#/workflows/${item.summary_fields.source_workflow_job.id}`; - } - - // Set the item type label - if (list.fields.type && $scope.options && - $scope.options.hasOwnProperty('type')) { - $scope.options.type.choices.forEach(function(choice) { - if (choice[0] === item.type) { - itm.type_label = choice[1]; - } - }); - } - buildTooltips(itm); - }); + vm.tab = { + details: { + _go: 'instanceGroups.edit', + _params: { instance_group_id }, + _label: strings.get('tab.DETAILS') + }, + instances: { + _go: 'instanceGroups.instances', + _params: { instance_group_id }, + _label: strings.get('tab.INSTANCES') + }, + jobs: { + _active: true, + _label: strings.get('tab.JOBS') } - } - - function buildTooltips(job) { - job.status_tip = 'Job ' + job.status + ". Click for details."; - } - - $scope.viewjobResults = function(job) { - var goTojobResults = function(state) { - $state.go(state, { id: job.id }, { reload: true }); - }; - switch (job.type) { - case 'job': - goTojobResults('jobResult'); - break; - case 'ad_hoc_command': - goTojobResults('adHocJobStdout'); - break; - case 'system_job': - goTojobResults('managementJobStdout'); - break; - case 'project_update': - goTojobResults('scmUpdateStdout'); - break; - case 'inventory_update': - goTojobResults('inventorySyncStdout'); - break; - case 'workflow_job': - goTojobResults('workflowResults'); - break; - } - }; - - $scope.$watchCollection(`${$scope.list.name}`, function() { - optionsRequestDataProcessing(); - } - ); } -]; \ No newline at end of file + + vm.getTime = function(time) { + let val = ""; + if (time) { + val += $filter('longDate')(time); + } + if (val === "") { + val = undefined; + } + return val; + }; + + $scope.isSuccessful = function (status) { + return (status === "successful"); + }; + + $scope.viewjobResults = function(job) { + var goTojobResults = function(state) { + $state.go(state, { id: job.id }, { reload: true }); + }; + switch (job.type) { + case 'job': + goTojobResults('jobResult'); + break; + case 'ad_hoc_command': + goTojobResults('adHocJobStdout'); + break; + case 'system_job': + goTojobResults('managementJobStdout'); + break; + case 'project_update': + goTojobResults('scmUpdateStdout'); + break; + case 'inventory_update': + goTojobResults('inventorySyncStdout'); + break; + case 'workflow_job': + goTojobResults('workflowResults'); + break; + } + + }; +} + +InstanceGroupJobsController.$inject = [ + '$scope', + 'GetBasePath', + 'Rest', + 'Dataset', + 'Find', + '$filter', + '$state', + '$q', + 'resolvedModels', + 'InstanceGroupsStrings', + 'JobStrings' +]; + +export default InstanceGroupJobsController; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/jobs/jobs.list.js b/awx/ui/client/src/instance-groups/jobs/jobs.list.js index 59e14ba19b..061674054d 100644 --- a/awx/ui/client/src/instance-groups/jobs/jobs.list.js +++ b/awx/ui/client/src/instance-groups/jobs/jobs.list.js @@ -1,76 +1,76 @@ -export default ['i18n', function (i18n) { - return { - name: 'jobs', - iterator: 'job', - basePath: 'api/v2/instance_groups/{{$stateParams.instance_group_id}}/jobs/', - index: false, - hover: false, - well: true, - emptyListText: i18n._('No jobs have yet run.'), - listTitle: false, +// export default ['i18n', function (i18n) { +// return { +// name: 'jobs', +// iterator: 'job', +// basePath: 'api/v2/instance_groups/{{$stateParams.instance_group_id}}/jobs/', +// index: false, +// hover: false, +// well: true, +// emptyListText: i18n._('No jobs have yet run.'), +// listTitle: false, - fields: { - status: { - label: '', - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus', - dataTipWatch: 'job.status_tip', - awToolTip: "{{ job.status_tip }}", - awTipPlacement: "right", - dataTitle: "{{ job.status_popover_title }}", - icon: 'icon-job-{{ job.status }}', - iconOnly: true, - ngClick: "viewjobResults(job)", - nosort: true - }, - id: { - label: i18n._('ID'), - ngClick: "viewjobResults(job)", - columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumnAdjacent', - awToolTip: "{{ job.status_tip }}", - dataPlacement: 'top', - noLink: true - }, - name: { - label: i18n._('Name'), - columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6', - ngClick: "viewjobResults(job)", - badgePlacement: 'right', - badgeCustom: true, - nosort: true, - badgeIcon: ` - - W - - ` - }, - type: { - label: i18n._('Type'), - ngBind: 'job.type_label', - columnClass: "col-lg-2 hidden-md hidden-sm hidden-xs", - nosort: true - }, - finished: { - label: i18n._('Finished'), - noLink: true, - filter: "longDate", - columnClass: "col-lg-2 col-md-3 col-sm-3 hidden-xs", - key: true, - desc: true, - nosort: true - }, - labels: { - label: i18n._('Labels'), - type: 'labels', - nosort: true, - showDelete: false, - columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs', - sourceModel: 'labels', - sourceField: 'name' - }, - } - }; -}]; +// fields: { +// status: { +// label: '', +// columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus', +// dataTipWatch: 'job.status_tip', +// awToolTip: "{{ job.status_tip }}", +// awTipPlacement: "right", +// dataTitle: "{{ job.status_popover_title }}", +// icon: 'icon-job-{{ job.status }}', +// iconOnly: true, +// ngClick: "viewjobResults(job)", +// nosort: true +// }, +// id: { +// label: i18n._('ID'), +// ngClick: "viewjobResults(job)", +// columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumnAdjacent', +// awToolTip: "{{ job.status_tip }}", +// dataPlacement: 'top', +// noLink: true +// }, +// name: { +// label: i18n._('Name'), +// columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6', +// ngClick: "viewjobResults(job)", +// badgePlacement: 'right', +// badgeCustom: true, +// nosort: true, +// badgeIcon: ` +// +// W +// +// ` +// }, +// type: { +// label: i18n._('Type'), +// ngBind: 'job.type_label', +// columnClass: "col-lg-2 hidden-md hidden-sm hidden-xs", +// nosort: true +// }, +// finished: { +// label: i18n._('Finished'), +// noLink: true, +// filter: "longDate", +// columnClass: "col-lg-2 col-md-3 col-sm-3 hidden-xs", +// key: true, +// desc: true, +// nosort: true +// }, +// labels: { +// label: i18n._('Labels'), +// type: 'labels', +// nosort: true, +// showDelete: false, +// columnClass: 'List-tableCell col-lg-4 col-md-4 hidden-sm hidden-xs', +// sourceModel: 'labels', +// sourceField: 'name' +// }, +// } +// }; +// }]; diff --git a/awx/ui/client/src/instance-groups/jobs/jobs.strings.js b/awx/ui/client/src/instance-groups/jobs/jobs.strings.js new file mode 100644 index 0000000000..099f7a61b9 --- /dev/null +++ b/awx/ui/client/src/instance-groups/jobs/jobs.strings.js @@ -0,0 +1,30 @@ +function JobStrings (BaseString) { + BaseString.call(this, 'jobs'); + + const { t } = this; + const ns = this.jobs; + + ns.state = { + LIST_BREADCRUMB_LABEL: t.s('JOBS') + } + + ns.list = { + PANEL_TITLE: t.s('JOBS'), + ADD_BUTTON_LABEL: t.s('ADD'), + ADD_DD_JT_LABEL: t.s('Job Template'), + ADD_DD_WF_LABEL: t.s('Workflow Template'), + ROW_ITEM_LABEL_ACTIVITY: t.s('Activity'), + ROW_ITEM_LABEL_INVENTORY: t.s('Inventory'), + ROW_ITEM_LABEL_PROJECT: t.s('Project'), + ROW_ITEM_LABEL_TEMPLATE: t.s('Template'), + ROW_ITEM_LABEL_CREDENTIALS: t.s('Credentials'), + ROW_ITEM_LABEL_MODIFIED: t.s('Last Modified'), + ROW_ITEM_LABEL_RAN: t.s('Last Ran'), + ROW_ITEM_LABEL_STARTED: t.s('Started'), + ROW_ITEM_LABEL_FINISHED: t.s('Finished') + } +} + +JobStrings.$inject = ['BaseStringService']; + +export default JobStrings; diff --git a/awx/ui/client/src/instance-groups/jobs/list.view.html b/awx/ui/client/src/instance-groups/jobs/list.view.html new file mode 100644 index 0000000000..bd36b97548 --- /dev/null +++ b/awx/ui/client/src/instance-groups/jobs/list.view.html @@ -0,0 +1,103 @@ + + + {{ vm.panelTitle }} + + + {{:: vm.strings.get('tab.DETAILS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} + + + +
+ + +
+ + + + +
+ + + + + + + + + + + + + + + + + +
+
+ + + + + + +
+
+
+ + +
+
diff --git a/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js b/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js index 381e2419bf..4f1145ee1c 100644 --- a/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js +++ b/awx/ui/client/src/instance-groups/list/instance-groups-list.controller.js @@ -1,19 +1,43 @@ -export default ['$scope', 'InstanceGroupList', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', - function($scope, InstanceGroupList, GetBasePath, Rest, Dataset, Find, $state) { +export default ['$scope', 'InstanceGroupList', 'resolvedModels', 'GetBasePath', 'Rest', 'Dataset','Find', '$state', '$q', 'ComponentsStrings', + function($scope, InstanceGroupList, resolvedModels, GetBasePath, Rest, Dataset, Find, $state, $q, strings) { let list = InstanceGroupList; + const vm = this; + const { instanceGroup } = resolvedModels; init(); function init(){ + vm.panelTitle = strings.get('layout.INSTANCE_GROUPS'); $scope.list = list; $scope[`${list.iterator}_dataset`] = Dataset.data; $scope[list.name] = $scope[`${list.iterator}_dataset`].results; $scope.instanceGroupCount = Dataset.data.count; } - $scope.isActive = function(id) { - let selected = parseInt($state.params.instance_group_id); - return id === selected; + $scope.selection = {}; + + $scope.$watch('$state.params.instance_group_id', () => { + vm.activeId = parseInt($state.params.instance_group_id); + }); + + vm.delete = () => { + let deletables = $scope.selection; + deletables = Object.keys(deletables).filter((n) => deletables[n]); + //refactor + deletables.forEach((data) => { + let promise = instanceGroup.http.delete({resource: data}) + Promise.resolve(promise).then(vm.onSaveSuccess); + }); + } + + vm.onSaveSuccess = () => { + $state.transitionTo($state.current, $state.params, { + reload: true, location: true, inherit: false, notify: true + }); + } + + $scope.createInstanceGroup = () => { + $state.go('instanceGroups.add'); }; } -]; \ No newline at end of file +]; diff --git a/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html b/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html index f3d470afd9..ae2f5df32d 100644 --- a/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html +++ b/awx/ui/client/src/instance-groups/list/instance-groups-list.partial.html @@ -1,63 +1,84 @@ -
-
-
- INSTANCE GROUPS -
+ + + {{ vm.panelTitle }} {{ instanceGroupCount }} -
-
+ - - + +
+ + + +
+
+ +
+ +
+
-
PLEASE ADD ITEMS TO THIS LIST
+ + -
- - - - - - - - - - - - - - - - -
- "{{'Name' | translate}}" - - - Running Jobs - - Used Capacity -
- {{ instance_group.name }} - {{ instance_group.instances }} - - - {{ instance_group.jobs_running }} - - - -
-
+ + +
+ + + +
+ + + + + +
+ +
+ +
+ +
+
+
+
+ + + - - diff --git a/awx/ui/client/src/instance-groups/main.js b/awx/ui/client/src/instance-groups/main.js index 4f9410f0e0..8489cf83f5 100644 --- a/awx/ui/client/src/instance-groups/main.js +++ b/awx/ui/client/src/instance-groups/main.js @@ -1,58 +1,331 @@ import InstanceGroupsList from './list/instance-groups-list.controller'; import instanceGroupsMultiselect from '../shared/instance-groups-multiselect/instance-groups.directive'; import instanceGroupsModal from '../shared/instance-groups-multiselect/instance-groups-modal/instance-groups-modal.directive'; -import instanceGroupsRoute from './instance-groups.route'; -import instancesListRoute from './instances/instances-list.route'; -import JobsList from './jobs/jobs.list'; -import jobsListRoute from './jobs/jobs-list.route'; -import JobsListController from './jobs/jobs.controller'; -import InstanceList from './instances/instances.list'; -import instancesRoute from './instances/instances.route'; +import InstanceGroupJobsListController from './jobs/jobs.controller'; import InstanceListController from './instances/instances.controller'; -import InstanceJobsList from './instances/instance-jobs/instance-jobs.list'; -import instanceJobsRoute from './instances/instance-jobs/instance-jobs.route'; -import instanceJobsListRoute from './instances/instance-jobs/instance-jobs-list.route'; import InstanceJobsController from './instances/instance-jobs/instance-jobs.controller'; import CapacityBar from './capacity-bar/main'; import list from './instance-groups.list'; import service from './instance-groups.service'; -export default -angular.module('instanceGroups', [CapacityBar.name]) +import { templateUrl } from '../shared/template-url/template-url.factory'; + +import addEditTemplate from './add-edit/add-edit-instance-groups.view.html'; +import addInstanceModalTemplate from './add-edit/add-instance-list-policy.partial.html'; +import addInstanceModalController from './add-edit/add-instance-list-policy.controller.js'; +import instancesTemplate from './instances/instances-list.partial.html'; +import instanceModalTemplate from './instances/instance-modal.partial.html'; +import instanceModalController from './instances/instance-modal.controller.js'; +import AddInstanceGroupController from './add-edit/add-instance-group.controller'; +import EditInstanceGroupController from './add-edit/edit-instance-group.controller'; +import InstanceGroupsStrings from './instance-groups.strings'; +import JobStrings from './jobs/jobs.strings'; + +import jobsTemplate from './jobs/list.view.html'; + +const MODULE_NAME = 'instanceGroups'; + +function InstanceGroupsResolve ($q, $stateParams, InstanceGroup, Instance, Job) { + const instanceGroupId = $stateParams.instance_group_id; + const instanceId = $stateParams.instance_id; + let promises = {}; + + if (!instanceGroupId && !instanceId) { + promises.instanceGroup = new InstanceGroup(['get', 'options']) + promises.instance = new Instance(['get', 'options']); + + return $q.all(promises); + } + + if (instanceGroupId && instanceId) { + promises.instance = new Instance(['get', 'options'], [instanceId, instanceId]) + .then((instance) => instance.extend('get', 'jobs', {params: {page_size: "10", order_by: "-finished"}})) + return $q.all(promises); + } + + promises.instanceGroup = new InstanceGroup(['get', 'options'], [instanceGroupId, instanceGroupId]) + .then((instanceGroup) => instanceGroup.extend('get', 'jobs', {params: {page_size: "10", order_by: "-finished"}})) + .then((instanceGroup) => instanceGroup.extend('get', 'instances')) + + promises.instance = new Instance('get'); + + return $q.all(promises) + .then(models => models); +} + +InstanceGroupsResolve.$inject = [ + '$q', + '$stateParams', + 'InstanceGroupModel', + 'InstanceModel', + 'JobModel' +]; + +function InstanceGroupsRun ($stateExtender, strings, ComponentsStrings) { + $stateExtender.addState({ + name: 'instanceGroups', + url: '/instance_groups', + searchPrefix: 'instance_group', + ncyBreadcrumb: { + label: ComponentsStrings.get('layout.INSTANCE_GROUPS') + }, + params: { + instance_group_search: { + value: { + page_size: '10', + order_by: 'name' + }, + dynamic: true + } + }, + data: { + alwaysShowRefreshButton: true, + }, + views: { + '@': { + templateUrl: templateUrl('./instance-groups/instance-groups'), + }, + 'list@instanceGroups': { + templateUrl: templateUrl('./instance-groups/list/instance-groups-list'), + controller: 'InstanceGroupsList', + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve, + Dataset: ['InstanceGroupList', 'QuerySet', '$stateParams', 'GetBasePath', + function(list, qs, $stateParams, GetBasePath) { + let path = GetBasePath(list.basePath) || GetBasePath(list.name); + return qs.search(path, $stateParams[`${list.iterator}_search`]); + } + ] + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.add', + url: '/add', + ncyBreadcrumb: { + label: strings.get('state.ADD_BREADCRUMB_LABEL') + }, + views: { + 'add@instanceGroups': { + templateUrl: addEditTemplate, + controller: AddInstanceGroupController, + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.add.modal', + abstract: true, + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + template: ` + `, + } + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.add.modal.instances', + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + templateUrl: addInstanceModalTemplate, + controller: addInstanceModalController, + controllerAs: 'vm' + } + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.edit', + route: '/:instance_group_id', + ncyBreadcrumb: { + label: strings.get('state.EDIT_BREADCRUMB_LABEL') + }, + views: { + 'edit@instanceGroups': { + templateUrl: addEditTemplate, + controller: EditInstanceGroupController, + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }); + + + $stateExtender.addState({ + name: 'instanceGroups.edit.modal', + abstract: true, + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + template: ` + `, + } + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.edit.modal.instances', + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + templateUrl: addInstanceModalTemplate, + controller: addInstanceModalController, + controllerAs: 'vm' + } + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.instances', + url: '/:instance_group_id/instances', + ncyBreadcrumb: { + parent: 'instanceGroups.edit', + label: ComponentsStrings.get('layout.INSTANCES') + }, + params: { + instance_search: { + value: { + page_size: '10', + order_by: 'hostname' + }, + dynamic: true + } + }, + views: { + 'instances@instanceGroups': { + templateUrl: instancesTemplate, + controller: 'InstanceListController', + controllerAs: 'vm' + } + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.instances.modal', + abstract: true, + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + template: ` + `, + } + } + }); + + $stateExtender.addState({ + name: 'instanceGroups.instances.modal.add', + ncyBreadcrumb: { + skip: true, + }, + views: { + "modal": { + templateUrl: instanceModalTemplate, + controller: instanceModalController, + controllerAs: 'vm' + } + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.instanceJobs', + url: '/:instance_group_id/instances/:instance_id/jobs', + ncyBreadcrumb: { + parent: 'instanceGroups.instances', + label: ComponentsStrings.get('layout.JOBS') + }, + views: { + 'instanceJobs@instanceGroups': { + templateUrl: jobsTemplate, + controller: 'InstanceJobsController', + controllerAs: 'vm' + }, + }, + params: { + job_search: { + value: { + page_size: '10', + order_by: '-finished' + }, + dynamic: true + }, + }, + resolvedModels: InstanceGroupsResolve + }); + + $stateExtender.addState({ + name: 'instanceGroups.jobs', + url: '/:instance_group_id/jobs', + ncyBreadcrumb: { + parent: 'instanceGroups.edit', + label: ComponentsStrings.get('layout.JOBS') + }, + params: { + job_search: { + value: { + page_size: '10', + order_by: '-finished' + }, + dynamic: true + } + }, + views: { + 'jobs@instanceGroups': { + templateUrl: jobsTemplate, + controller: 'InstanceGroupJobsListController', + controllerAs: 'vm' + }, + }, + resolve: { + resolvedModels: InstanceGroupsResolve + } + }) +} + +InstanceGroupsRun.$inject = [ + '$stateExtender', + 'InstanceGroupsStrings', + 'ComponentsStrings' +]; + +angular.module(MODULE_NAME, [CapacityBar.name]) .service('InstanceGroupsService', service) .factory('InstanceGroupList', list) - .factory('JobsList', JobsList) - .factory('InstanceList', InstanceList) - .factory('InstanceJobsList', InstanceJobsList) .controller('InstanceGroupsList', InstanceGroupsList) - .controller('JobsListController', JobsListController) + .controller('InstanceGroupJobsListController', InstanceGroupJobsListController) .controller('InstanceListController', InstanceListController) .controller('InstanceJobsController', InstanceJobsController) .directive('instanceGroupsMultiselect', instanceGroupsMultiselect) .directive('instanceGroupsModal', instanceGroupsModal) - .config(['$stateProvider', 'stateDefinitionsProvider', '$stateExtenderProvider', - function($stateProvider, stateDefinitionsProvider, $stateExtenderProvider) { - let stateExtender = $stateExtenderProvider.$get(); + .service('InstanceGroupsStrings', InstanceGroupsStrings) + .service('JobStrings', JobStrings) + .run(InstanceGroupsRun); - - function generateInstanceGroupsStates() { - return new Promise((resolve) => { - resolve({ - states: [ - stateExtender.buildDefinition(instanceGroupsRoute), - stateExtender.buildDefinition(instancesRoute), - stateExtender.buildDefinition(instancesListRoute), - stateExtender.buildDefinition(jobsListRoute), - stateExtender.buildDefinition(instanceJobsRoute), - stateExtender.buildDefinition(instanceJobsListRoute) - ] - }); - }); - } - - $stateProvider.state({ - name: 'instanceGroups.**', - url: '/instance_groups', - lazyLoad: () => generateInstanceGroupsStates() - }); - }]); +export default MODULE_NAME; diff --git a/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html b/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html index ba96157381..48eb41b1b5 100644 --- a/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html +++ b/awx/ui/client/src/shared/multi-select-preview/multi-select-preview.partial.html @@ -11,6 +11,7 @@
{{selectedRow.name}} + {{selectedRow.hostname}}
From 342958ece38f1df13cb65587b4260662d174fe18 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 29 Jan 2018 11:09:00 -0500 Subject: [PATCH 35/82] Add stringToNumber directive --- awx/ui/client/src/shared/directives.js | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/awx/ui/client/src/shared/directives.js b/awx/ui/client/src/shared/directives.js index 2013c4392c..5df24c2450 100644 --- a/awx/ui/client/src/shared/directives.js +++ b/awx/ui/client/src/shared/directives.js @@ -38,7 +38,7 @@ angular.module('AWDirectives', ['RestServices', 'Utilities']) }; }) -// caplitalize Add to any input field where the first letter of each +// capitalize Add to any input field where the first letter of each // word should be capitalized. Use in place of css test-transform. // For some reason "text-transform: capitalize" in breadcrumbs // causes a break at each blank space. And of course, @@ -65,6 +65,26 @@ angular.module('AWDirectives', ['RestServices', 'Utilities']) }; }) +// stringToNumber +// +// If your model does not contain actual numbers then this directive +// will do the conversion in the ngModel $formatters and $parsers pipeline. +// +.directive('stringToNumber', function() { + return { + require: 'ngModel', + restrict: 'A', + link: function(scope, element, attrs, ngModel) { + ngModel.$parsers.push(function(value) { + return '' + value; + }); + ngModel.$formatters.push(function(value) { + return parseFloat(value); + }); + } + }; +}) + // imageUpload // // Accepts image and returns base64 information with basic validation From 70786c53a77443d944b3e34a62274f7fc57e1847 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Mon, 29 Jan 2018 13:11:45 -0500 Subject: [PATCH 36/82] Add capacity adjuster directive --- .../capacity-adjuster.directive.js | 45 +++++++++++++++++++ .../capacity-adjuster.partial.html | 14 ++++++ .../instances/instances-list.partial.html | 1 + .../instances/instances.controller.js | 5 ++- awx/ui/client/src/instance-groups/main.js | 2 + 5 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js create mode 100644 awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html diff --git a/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js new file mode 100644 index 0000000000..65ce8755b2 --- /dev/null +++ b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.directive.js @@ -0,0 +1,45 @@ +function CapacityAdjuster (templateUrl, $http) { + return { + scope: { + state: '=' + }, + templateUrl: templateUrl('instance-groups/capacity-adjuster/capacity-adjuster'), + restrict: 'E', + link: function(scope) { + let adjustment_values = [{ + label: 'CPU', + value: scope.state.cpu_capacity, + },{ + label: 'RAM', + value: scope.state.mem_capacity + }]; + + scope.min_capacity = _.min(adjustment_values, 'value'); + scope.max_capacity = _.max(adjustment_values, 'value'); + + }, + controller: function($http, $scope) { + const vm = this || {}; + + vm.slide = (state) => { + let data = { + "capacity_adjustment": state.capacity_adjustment + }; + let req = { + method: 'PUT', + url: state.url, + data + }; + $http(req); + } + }, + controllerAs: 'vm' + }; +} + +CapacityAdjuster.$inject = [ + 'templateUrl', + '$http' +]; + +export default CapacityAdjuster; \ No newline at end of file diff --git a/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html new file mode 100644 index 0000000000..d3200f49eb --- /dev/null +++ b/awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.partial.html @@ -0,0 +1,14 @@ +
+

{{min_capacity.label}}

+

{{min_capacity.value}}

+ +

{{max_capacity.label}}

+

{{max_capacity.value}}

+
diff --git a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html index 814588a101..8f97077cf8 100644 --- a/awx/ui/client/src/instance-groups/instances/instances-list.partial.html +++ b/awx/ui/client/src/instance-groups/instances/instances-list.partial.html @@ -54,6 +54,7 @@
+
diff --git a/awx/ui/client/src/instance-groups/instances/instances.controller.js b/awx/ui/client/src/instance-groups/instances/instances.controller.js index 6e8de76864..c8c7b3d3af 100644 --- a/awx/ui/client/src/instance-groups/instances/instances.controller.js +++ b/awx/ui/client/src/instance-groups/instances/instances.controller.js @@ -1,4 +1,4 @@ -function InstancesController ($scope, $state, models, strings, Dataset) { +function InstancesController ($scope, $state, $http, models, Instance, strings, Dataset) { const { instanceGroup } = models; const vm = this || {}; vm.strings = strings; @@ -37,7 +37,6 @@ function InstancesController ($scope, $state, models, strings, Dataset) { } }; - $scope.isActive = function(id) { let selected = parseInt($state.params.instance_id); return id === selected; @@ -47,7 +46,9 @@ function InstancesController ($scope, $state, models, strings, Dataset) { InstancesController.$inject = [ '$scope', '$state', + '$http', 'resolvedModels', + 'InstanceModel', 'InstanceGroupsStrings', 'Dataset' ]; diff --git a/awx/ui/client/src/instance-groups/main.js b/awx/ui/client/src/instance-groups/main.js index 8489cf83f5..06afc31c66 100644 --- a/awx/ui/client/src/instance-groups/main.js +++ b/awx/ui/client/src/instance-groups/main.js @@ -5,6 +5,7 @@ import InstanceGroupJobsListController from './jobs/jobs.controller'; import InstanceListController from './instances/instances.controller'; import InstanceJobsController from './instances/instance-jobs/instance-jobs.controller'; import CapacityBar from './capacity-bar/main'; +import CapacityAdjuster from './capacity-adjuster/capacity-adjuster.directive'; import list from './instance-groups.list'; import service from './instance-groups.service'; @@ -324,6 +325,7 @@ angular.module(MODULE_NAME, [CapacityBar.name]) .controller('InstanceJobsController', InstanceJobsController) .directive('instanceGroupsMultiselect', instanceGroupsMultiselect) .directive('instanceGroupsModal', instanceGroupsModal) + .directive('capacityAdjuster', CapacityAdjuster) .service('InstanceGroupsStrings', InstanceGroupsStrings) .service('JobStrings', JobStrings) .run(InstanceGroupsRun); From e07f441e3236055f3f4b2e4cbc74cae7a735d8f6 Mon Sep 17 00:00:00 2001 From: Marliana Lara Date: Tue, 30 Jan 2018 15:25:36 -0500 Subject: [PATCH 37/82] Add Instance enable/disable toggle to list --- .../lib/components/components.strings.js | 5 - .../lib/components/input/lookup.directive.js | 10 +- .../lib/components/input/lookup.partial.html | 73 ++++----- awx/ui/client/lib/components/list/_index.less | 17 ++ awx/ui/client/lib/components/tabs/_index.less | 4 + awx/ui/client/lib/models/Job.js | 21 --- .../lib/services/base-string.service.js | 2 + awx/ui/client/lib/theme/_global.less | 1 + awx/ui/client/lib/theme/index.less | 1 + awx/ui/client/src/app.js | 15 -- .../add-edit-instance-groups.view.html | 8 +- .../add-edit/add-instance-group.controller.js | 4 +- .../edit-instance-group.controller.js | 3 +- ....js => instance-list-policy.controller.js} | 28 +++- ...html => instance-list-policy.partial.html} | 30 ++-- .../capacity-adjuster.block.less | 11 ++ .../capacity-adjuster.directive.js | 9 +- .../capacity-adjuster.partial.html | 9 +- .../capacity-bar/capacity-bar.directive.js | 2 +- .../capacity-bar/capacity-bar.partial.html | 2 +- .../instance-group.partial.html | 33 ---- .../instance-groups.strings.js | 7 +- .../instance-jobs/instance-jobs.controller.js | 2 +- .../instances/instance-modal.controller.js | 10 +- .../instances/instance-modal.partial.html | 26 +-- .../instances/instances-list.partial.html | 26 ++- .../instances/instances.controller.js | 23 +++ ...{list.view.html => jobs-list.partial.html} | 28 +--- .../instance-groups/jobs/jobs.controller.js | 2 +- .../src/instance-groups/jobs/jobs.list.js | 150 +++++++++--------- .../src/instance-groups/jobs/jobs.strings.js | 4 +- .../list/instance-groups-list.controller.js | 11 +- .../list/instance-groups-list.partial.html | 6 +- awx/ui/client/src/instance-groups/main.js | 87 +++++----- 34 files changed, 328 insertions(+), 342 deletions(-) delete mode 100644 awx/ui/client/lib/models/Job.js rename awx/ui/client/src/instance-groups/add-edit/{add-instance-list-policy.controller.js => instance-list-policy.controller.js} (52%) rename awx/ui/client/src/instance-groups/add-edit/{add-instance-list-policy.partial.html => instance-list-policy.partial.html} (68%) create mode 100644 awx/ui/client/src/instance-groups/capacity-adjuster/capacity-adjuster.block.less delete mode 100644 awx/ui/client/src/instance-groups/instance-group.partial.html rename awx/ui/client/src/instance-groups/jobs/{list.view.html => jobs-list.partial.html} (76%) diff --git a/awx/ui/client/lib/components/components.strings.js b/awx/ui/client/lib/components/components.strings.js index ddc765533b..810fe5338a 100644 --- a/awx/ui/client/lib/components/components.strings.js +++ b/awx/ui/client/lib/components/components.strings.js @@ -78,11 +78,6 @@ function ComponentsStrings (BaseString) { FOOTER_COPYRIGHT: t.s('Copyright © 2017 Red Hat, Inc.') }; - ns.capacityBar = { - IS_OFFLINE: t.s('Unavailable to run jobs.'), - IS_OFFLINE_LABEL: t.s('Unavailable') - }; - ns.relaunch = { DEFAULT: t.s('Relaunch using the same parameters'), HOSTS: t.s('Relaunch using host parameters'), diff --git a/awx/ui/client/lib/components/input/lookup.directive.js b/awx/ui/client/lib/components/input/lookup.directive.js index fcee7ad72c..0447d6b448 100644 --- a/awx/ui/client/lib/components/input/lookup.directive.js +++ b/awx/ui/client/lib/components/input/lookup.directive.js @@ -119,14 +119,8 @@ function AtInputLookupController (baseInputController, $q, $state) { vm.searchAfterDebounce(); }; - vm.removeTag = (i) => { - let list; - if (!i.id) { - list = _.remove(scope.state._value, i); - } else { - list = _.remove(scope.state._value, i.id); - } - scope.state._value = list; + vm.removeTag = (tagToRemove) => { + _.remove(scope.state._value, (tag) => tag === tagToRemove); }; } diff --git a/awx/ui/client/lib/components/input/lookup.partial.html b/awx/ui/client/lib/components/input/lookup.partial.html index 271c24212f..e3633aa743 100644 --- a/awx/ui/client/lib/components/input/lookup.partial.html +++ b/awx/ui/client/lib/components/input/lookup.partial.html @@ -1,45 +1,40 @@
-
- +
+ -
- - - +
+ + + + - - - - - - -
-
- -
-
- {{ tag.hostname }} - {{ tag }} -
-
-
-
- - + +
+
+ +
+
+ {{ tag.hostname }} + {{ tag }} +
+
-
+ +
+ +
\ No newline at end of file diff --git a/awx/ui/client/lib/components/list/_index.less b/awx/ui/client/lib/components/list/_index.less index 77997bbbcc..a4daa3248b 100644 --- a/awx/ui/client/lib/components/list/_index.less +++ b/awx/ui/client/lib/components/list/_index.less @@ -99,6 +99,15 @@ } } +.at-RowStatus { + align-self: flex-start; + margin: 0 10px 0 0; +} + +.at-Row-firstColumn { + margin-right: @at-space-4x; +} + .at-Row-actions { display: flex; } @@ -120,6 +129,14 @@ line-height: @at-line-height-list-row-item-header; } +.at-RowItem--isHeaderLink { + color: @at-blue; + cursor: pointer; +} +.at-RowItem--isHeaderLink:hover { + color: @at-blue-hover; +} + .at-RowItem--labels { line-height: @at-line-height-list-row-item-labels; } diff --git a/awx/ui/client/lib/components/tabs/_index.less b/awx/ui/client/lib/components/tabs/_index.less index 4956444576..84b33c2134 100644 --- a/awx/ui/client/lib/components/tabs/_index.less +++ b/awx/ui/client/lib/components/tabs/_index.less @@ -26,3 +26,7 @@ cursor: not-allowed; } } + +.at-TabGroup + .at-Panel-body { + margin-top: 20px; +} \ No newline at end of file diff --git a/awx/ui/client/lib/models/Job.js b/awx/ui/client/lib/models/Job.js deleted file mode 100644 index 9be420b2f9..0000000000 --- a/awx/ui/client/lib/models/Job.js +++ /dev/null @@ -1,21 +0,0 @@ -let Base; - -function JobModel (method, resource, config) { - Base.call(this, 'jobs'); - - this.Constructor = JobModel; - - return this.create(method, resource, config); -} - -function JobModelLoader (BaseModel) { - Base = BaseModel; - - return JobModel; -} - -JobModelLoader.$inject = [ - 'BaseModel' -]; - -export default JobModelLoader; diff --git a/awx/ui/client/lib/services/base-string.service.js b/awx/ui/client/lib/services/base-string.service.js index a14871ae68..6c2622879d 100644 --- a/awx/ui/client/lib/services/base-string.service.js +++ b/awx/ui/client/lib/services/base-string.service.js @@ -60,6 +60,8 @@ function BaseStringService (namespace) { this.CANCEL = t.s('CANCEL'); this.SAVE = t.s('SAVE'); this.OK = t.s('OK'); + this.ON = t.s('ON'); + this.OFF = t.s('OFF'); this.deleteResource = { HEADER: t.s('Delete'), USED_BY: resourceType => t.s('The {{ resourceType }} is currently being used by other resources.', { resourceType }), diff --git a/awx/ui/client/lib/theme/_global.less b/awx/ui/client/lib/theme/_global.less index b62f501c33..6995b224a5 100644 --- a/awx/ui/client/lib/theme/_global.less +++ b/awx/ui/client/lib/theme/_global.less @@ -23,6 +23,7 @@ font-size: 20px; } border-color: transparent; + margin-left: @at-space-2x; } .at-Button--info { diff --git a/awx/ui/client/lib/theme/index.less b/awx/ui/client/lib/theme/index.less index 6b3f241ffd..0a3d936978 100644 --- a/awx/ui/client/lib/theme/index.less +++ b/awx/ui/client/lib/theme/index.less @@ -72,6 +72,7 @@ @import '../../src/home/dashboard/lists/dashboard-list.block.less'; @import '../../src/home/dashboard/dashboard.block.less'; @import '../../src/instance-groups/capacity-bar/capacity-bar.block.less'; +@import '../../src/instance-groups/capacity-adjuster/capacity-adjuster.block.less'; @import '../../src/instance-groups/instance-group.block.less'; @import '../../src/instance-groups/instances/instance-modal.block.less'; @import '../../src/inventories-hosts/inventories/insights/insights.block.less'; diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 069ee2dd80..e95779eb38 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -316,21 +316,6 @@ angular activateTab(); }); - $transitions.onCreate({}, function(trans) { - console.log('$onCreate ' +trans.to().name); - }); - - $transitions.onBefore({}, function(trans) { - console.log('$onBefore ' +trans.to().name); - }); - $transitions.onError({}, function(trans) { - - console.log('$onError ' +trans.to().name); - }); - $transitions.onExit({}, function(trans) { - console.log('$onExit ' +trans.to().name); - }); - $transitions.onSuccess({}, function(trans) { if(trans.to() === trans.from()) { diff --git a/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html index a980d74ec8..8567f095ce 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html +++ b/awx/ui/client/src/instance-groups/add-edit/add-edit-instance-groups.view.html @@ -5,23 +5,19 @@ {{:: vm.strings.get('tab.DETAILS') }} - {{:: vm.strings.get('tab.INSTANCES') }} - {{:: vm.strings.get('tab.JOBS') }} + {{:: vm.strings.get('tab.INSTANCES') }} + {{:: vm.strings.get('tab.JOBS') }} - - - -
diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js index 9fc838115b..bb0b3ea413 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/add-instance-group.controller.js @@ -1,11 +1,10 @@ function AddController ($scope, $state, models, strings) { const vm = this || {}; - const { instanceGroup, instance } = models; vm.mode = 'add'; vm.strings = strings; - vm.panelTitle = "New Instance Group"; + vm.panelTitle = strings.get('state.ADD_BREADCRUMB_LABEL'); vm.tab = { details: { _active: true }, @@ -15,6 +14,7 @@ function AddController ($scope, $state, models, strings) { vm.form = instanceGroup.createFormSchema('post'); + // Default policy instance percentage value is 0 vm.form.policy_instance_percentage._value = 0; vm.form.policy_instance_list._lookupTags = true; diff --git a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js index 49b197c6ec..ee0802bbe2 100644 --- a/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/edit-instance-group.controller.js @@ -1,6 +1,5 @@ function EditController ($rootScope, $state, models, strings) { const vm = this || {}; - const { instanceGroup, instance } = models; $rootScope.breadcrumb.instance_group_name = instanceGroup.get('name'); @@ -36,7 +35,7 @@ function EditController ($rootScope, $state, models, strings) { vm.form.save = data => { instanceGroup.unset('policy_instance_list'); - data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname); + data.policy_instance_list = data.policy_instance_list.map(instance => instance.hostname || instance); return instanceGroup.request('put', { data }); }; diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.controller.js similarity index 52% rename from awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js rename to awx/ui/client/src/instance-groups/add-edit/instance-list-policy.controller.js index c7819ba797..f70150a79c 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.controller.js +++ b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.controller.js @@ -1,5 +1,5 @@ function InstanceModalController ($scope, $state, $http, $q, models, strings) { - const { instance } = models; + const { instance, instanceGroup } = models; const vm = this || {}; vm.setInstances = () => { @@ -7,18 +7,34 @@ function InstanceModalController ($scope, $state, $http, $q, models, strings) { instance.isSelected = false; return instance; }); - } + }; + + vm.setRelatedInstances = () => { + vm.instanceGroupName = instanceGroup.get('name'); + vm.relatedInstances = instanceGroup.get('policy_instance_list'); + + vm.instances = instance.get('results').map(instance => { + instance.isSelected = vm.relatedInstances.includes(instance.hostname); + return instance; + }); + }; init(); function init() { vm.strings = strings; - vm.panelTitle = strings.get('instance.PANEL_TITLE'); - vm.setInstances(); - }; + vm.instanceGroupId = instanceGroup.get('id'); + vm.defaultParams = { page_size: '10', order_by: 'hostname' }; + + if (vm.instanceGroupId === undefined) { + vm.setInstances(); + } else { + vm.setRelatedInstances(); + } + } $scope.$watch('vm.instances', function() { - vm.selectedRows = _.filter(vm.instances, 'isSelected') + vm.selectedRows = _.filter(vm.instances, 'isSelected'); vm.deselectedRows = _.filter(vm.instances, 'isSelected', false); }, true); diff --git a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html similarity index 68% rename from awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html rename to awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html index 29493add34..218e61421d 100644 --- a/awx/ui/client/src/instance-groups/add-edit/add-instance-list-policy.partial.html +++ b/awx/ui/client/src/instance-groups/add-edit/instance-list-policy.partial.html @@ -1,17 +1,17 @@