diff --git a/awx/api/authentication.py b/awx/api/authentication.py index 298bbd5f96..23d8c462c6 100644 --- a/awx/api/authentication.py +++ b/awx/api/authentication.py @@ -7,7 +7,7 @@ from rest_framework import exceptions from rest_framework import HTTP_HEADER_ENCODING # AWX -from awx.main.models import Job, AuthToken +from awx.main.models import UnifiedJob, AuthToken class TokenAuthentication(authentication.TokenAuthentication): @@ -74,24 +74,26 @@ class TokenAuthentication(authentication.TokenAuthentication): # Return the user object and the token. return (token.user, token) -class JobTaskAuthentication(authentication.BaseAuthentication): +class TaskAuthentication(authentication.BaseAuthentication): ''' Custom authentication used for views accessed by the inventory and callback - scripts when running a job. + scripts when running a task. ''' - + + model = None + def authenticate(self, request): auth = authentication.get_authorization_header(request).split() if len(auth) != 2 or auth[0].lower() != 'token' or '-' not in auth[1]: return None - job_id, job_key = auth[1].split('-', 1) + pk, key = auth[1].split('-', 1) try: - job = Job.objects.get(pk=job_id, status='running') - except Job.DoesNotExist: + unified_job = UnifiedJob.objects.get(pk=pk, status='running') + except UnifiedJob.DoesNotExist: return None - token = job.task_auth_token + token = unified_job.task_auth_token if auth[1] != token: - raise exceptions.AuthenticationFailed('Invalid job task token') + raise exceptions.AuthenticationFailed('Invalid task token') return (None, token) def authenticate_header(self, request): diff --git a/awx/api/generics.py b/awx/api/generics.py index 2e7f07a753..78071f2222 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -29,8 +29,9 @@ from awx.main.utils import * # noqa __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView', 'ListCreateAPIView', 'SubListAPIView', 'SubListCreateAPIView', - 'RetrieveAPIView', 'RetrieveUpdateAPIView', - 'RetrieveDestroyAPIView', 'RetrieveUpdateDestroyAPIView', 'DestroyAPIView'] + 'SubListCreateAttachDetachAPIView', 'RetrieveAPIView', + 'RetrieveUpdateAPIView', 'RetrieveDestroyAPIView', + 'RetrieveUpdateDestroyAPIView', 'DestroyAPIView'] logger = logging.getLogger('awx.api.generics') @@ -131,12 +132,15 @@ class APIView(views.APIView): def get_description_context(self): return { + 'view': self, 'docstring': type(self).__doc__ or '', 'new_in_13': getattr(self, 'new_in_13', False), 'new_in_14': getattr(self, 'new_in_14', False), 'new_in_145': getattr(self, 'new_in_145', False), 'new_in_148': getattr(self, 'new_in_148', False), 'new_in_200': getattr(self, 'new_in_200', False), + 'new_in_210': getattr(self, 'new_in_210', False), + 'new_in_220': getattr(self, 'new_in_220', False), } def get_description(self, html=False): @@ -153,7 +157,7 @@ class APIView(views.APIView): ''' ret = super(APIView, self).metadata(request) added_in_version = '1.2' - for version in ('2.1.0', '2.0.0', '1.4.8', '1.4.5', '1.4', '1.3'): + for version in ('2.2.0', '2.1.0', '2.0.0', '1.4.8', '1.4.5', '1.4', '1.3'): if getattr(self, 'new_in_%s' % version.replace('.', ''), False): added_in_version = version break @@ -328,8 +332,8 @@ class SubListAPIView(ListAPIView): return qs & sublist_qs class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): - # Base class for a sublist view that allows for creating subobjects and - # attaching/detaching them from the parent. + # Base class for a sublist view that allows for creating subobjects + # associated with the parent object. # In addition to SubListAPIView properties, subclasses may define (if the # sub_obj requires a foreign key to the parent): @@ -374,8 +378,13 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): # object deserialized obj = serializer.save() serializer = self.serializer_class(obj) + + headers = {'Location': obj.get_absolute_url()} + return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) - return Response(serializer.data, status=status.HTTP_201_CREATED) +class SubListCreateAttachDetachAPIView(SubListCreateAPIView): + # Base class for a sublist view that allows for creating subobjects and + # attaching/detaching them from the parent. def attach(self, request, *args, **kwargs): created = False diff --git a/awx/api/permissions.py b/awx/api/permissions.py index a1b06b4b0b..1458ca1c45 100644 --- a/awx/api/permissions.py +++ b/awx/api/permissions.py @@ -19,7 +19,7 @@ from awx.main.utils import get_object_or_400 logger = logging.getLogger('awx.api.permissions') __all__ = ['ModelAccessPermission', 'JobTemplateCallbackPermission', - 'JobTaskPermission'] + 'TaskPermission'] class ModelAccessPermission(permissions.BasePermission): ''' @@ -160,31 +160,31 @@ class JobTemplateCallbackPermission(ModelAccessPermission): else: return True -class JobTaskPermission(ModelAccessPermission): +class TaskPermission(ModelAccessPermission): ''' Permission checks used for API callbacks from running a task. ''' def has_permission(self, request, view, obj=None): - # If another authentication method was used other than the one for job + # If another authentication method was used other than the one for # callbacks, default to the superclass permissions checking. if request.user or not request.auth: - return super(JobTaskPermission, self).has_permission(request, view, obj) + return super(TaskPermission, self).has_permission(request, view, obj) - # Verify that the job ID present in the auth token is for a valid, - # active job. + # Verify that the ID present in the auth token is for a valid, active + # unified job. try: - job = Job.objects.get(active=True, status='running', - pk=int(request.auth.split('-')[0])) - except (Job.DoesNotExist, TypeError): + unified_job = UnifiedJob.objects.get(active=True, status='running', + pk=int(request.auth.split('-')[0])) + except (UnifiedJob.DoesNotExist, TypeError): return False # Verify that the request method is one of those allowed for the given # view, also that the job or inventory being accessed matches the auth # token. if view.model == Inventory and request.method.lower() in ('head', 'get'): - return bool(not obj or obj.pk == job.inventory.pk) - elif view.model == JobEvent and request.method.lower() == 'post': - return bool(not obj or obj.pk == job.pk) + return bool(not obj or obj.pk == unified_job.inventory_id) + elif view.model in (JobEvent, AdHocCommandEvent) and request.method.lower() == 'post': + return bool(not obj or obj.pk == unified_job.pk) else: return False diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 3af2bbb8a2..63b7f51374 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -132,16 +132,17 @@ class BaseSerializerMetaclass(serializers.SerializerMetaclass): for attr in dir(other): if attr.startswith('_'): continue - val = getattr(other, attr) + meta_val = getattr(meta, attr, []) + val = getattr(other, attr, []) # Special handling for lists of strings (field names). if isinstance(val, (list, tuple)) and all([isinstance(x, basestring) for x in val]): new_vals = [] except_vals = [] if base: # Merge values from all bases. - new_vals.extend([x for x in getattr(meta, attr, [])]) + new_vals.extend([x for x in meta_val]) for v in val: if not base and v == '*': # Inherit all values from previous base(es). - new_vals.extend([x for x in getattr(meta, attr, [])]) + new_vals.extend([x for x in meta_val]) elif not base and v.startswith('-'): # Except these values. except_vals.append(v[1:]) else: @@ -226,6 +227,7 @@ class BaseSerializer(serializers.ModelSerializer): def get_type_choices(self): type_name_map = { 'job': 'Playbook Run', + 'ad_hoc_command': 'Ad Hoc Command', 'project_update': 'SCM Update', 'inventory_update': 'Inventory Sync', 'system_job': 'Management Job', @@ -347,6 +349,23 @@ class BaseSerializer(serializers.ModelSerializer): exclusions.remove(field_name) return exclusions + def to_native(self, obj): + # When rendering the raw data form, create an instance of the model so + # that the model defaults will be filled in. + view = self.context.get('view', None) + parent_key = getattr(view, 'parent_key', None) + if not obj and hasattr(view, '_raw_data_form_marker'): + obj = self.opts.model() + # FIXME: Would be nice to include any posted data for the raw data + # form, so that a submission with errors can be modified in place + # and resubmitted. + ret = super(BaseSerializer, self).to_native(obj) + # Remove parent key from raw form data, since it will be automatically + # set by the sub list create view. + if parent_key and hasattr(view, '_raw_data_form_marker'): + ret.pop(parent_key, None) + return ret + class UnifiedJobTemplateSerializer(BaseSerializer): @@ -390,6 +409,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer): class UnifiedJobSerializer(BaseSerializer): result_stdout = serializers.Field(source='result_stdout') + unified_job_template = serializers.Field(source='unified_job_template') class Meta: model = UnifiedJob @@ -400,7 +420,7 @@ class UnifiedJobSerializer(BaseSerializer): def get_types(self): if type(self) is UnifiedJobSerializer: - return ['project_update', 'inventory_update', 'job', 'system_job'] + return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job'] else: return super(UnifiedJobSerializer, self).get_types() @@ -416,6 +436,8 @@ class UnifiedJobSerializer(BaseSerializer): res['stdout'] = reverse('api:inventory_update_stdout', args=(obj.pk,)) elif isinstance(obj, Job): res['stdout'] = reverse('api:job_stdout', args=(obj.pk,)) + elif isinstance(obj, AdHocCommand): + res['stdout'] = reverse('api:ad_hoc_command_stdout', args=(obj.pk,)) return res def to_native(self, obj): @@ -427,6 +449,8 @@ class UnifiedJobSerializer(BaseSerializer): serializer_class = InventoryUpdateSerializer elif isinstance(obj, Job): serializer_class = JobSerializer + elif isinstance(obj, AdHocCommand): + serializer_class = AdHocCommandSerializer elif isinstance(obj, SystemJob): serializer_class = SystemJobSerializer if serializer_class: @@ -447,7 +471,7 @@ class UnifiedJobListSerializer(UnifiedJobSerializer): def get_types(self): if type(self) is UnifiedJobListSerializer: - return ['project_update', 'inventory_update', 'job', 'system_job'] + return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job'] else: return super(UnifiedJobListSerializer, self).get_types() @@ -460,6 +484,8 @@ class UnifiedJobListSerializer(UnifiedJobSerializer): serializer_class = InventoryUpdateListSerializer elif isinstance(obj, Job): serializer_class = JobListSerializer + elif isinstance(obj, AdHocCommand): + serializer_class = AdHocCommandListSerializer elif isinstance(obj, SystemJob): serializer_class = SystemJobListSerializer if serializer_class: @@ -479,7 +505,7 @@ class UnifiedJobStdoutSerializer(UnifiedJobSerializer): def get_types(self): if type(self) is UnifiedJobStdoutSerializer: - return ['project_update', 'inventory_update', 'job', 'system_job'] + return ['project_update', 'inventory_update', 'job', 'ad_hoc_command', 'system_job'] else: return super(UnifiedJobStdoutSerializer, self).get_types() @@ -746,6 +772,7 @@ class InventorySerializer(BaseSerializerWithVariables): inventory_sources = reverse('api:inventory_inventory_sources_list', args=(obj.pk,)), activity_stream = reverse('api:inventory_activity_stream_list', args=(obj.pk,)), scan_job_templates = reverse('api:inventory_scan_job_template_list', args=(obj.pk,)), + ad_hoc_commands = reverse('api:inventory_ad_hoc_commands_list', args=(obj.pk,)), )) if obj.organization and obj.organization.active: res['organization'] = reverse('api:organization_detail', args=(obj.organization.pk,)) @@ -784,6 +811,8 @@ class HostSerializer(BaseSerializerWithVariables): job_host_summaries = reverse('api:host_job_host_summaries_list', args=(obj.pk,)), activity_stream = reverse('api:host_activity_stream_list', args=(obj.pk,)), inventory_sources = reverse('api:host_inventory_sources_list', args=(obj.pk,)), + ad_hoc_commands = reverse('api:host_ad_hoc_commands_list', args=(obj.pk,)), + ad_hoc_command_events = reverse('api:host_ad_hoc_command_events_list', args=(obj.pk,)), )) if obj.inventory and obj.inventory.active: res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,)) @@ -884,6 +913,7 @@ class GroupSerializer(BaseSerializerWithVariables): job_host_summaries = reverse('api:group_job_host_summaries_list', args=(obj.pk,)), activity_stream = reverse('api:group_activity_stream_list', args=(obj.pk,)), inventory_sources = reverse('api:group_inventory_sources_list', args=(obj.pk,)), + ad_hoc_commands = reverse('api:group_ad_hoc_commands_list', args=(obj.pk,)), )) if obj.inventory and obj.inventory.active: res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,)) @@ -1174,7 +1204,7 @@ class PermissionSerializer(BaseSerializer): class Meta: model = Permission fields = ('*', 'user', 'team', 'project', 'inventory', - 'permission_type') + 'permission_type', 'run_ad_hoc_commands') def get_related(self, obj): res = super(PermissionSerializer, self).get_related(obj) @@ -1190,15 +1220,15 @@ class PermissionSerializer(BaseSerializer): def validate(self, attrs): # Can only set either user or team. - if attrs['user'] and attrs['team']: + if attrs.get('user', None) and attrs.get('team', None): raise serializers.ValidationError('permission can only be assigned' ' to a user OR a team, not both') # Cannot assign admit/read/write permissions for a project. - if attrs['permission_type'] in ('admin', 'read', 'write') and attrs['project']: + if attrs.get('permission_type', None) in ('admin', 'read', 'write') and attrs.get('project', None): raise serializers.ValidationError('project cannot be assigned for ' 'inventory-only permissions') # Project is required when setting deployment permissions. - if attrs['permission_type'] in ('run', 'check') and not attrs['project']: + if attrs.get('permission_type', None) in ('run', 'check') and not attrs.get('project', None): raise serializers.ValidationError('project is required when ' 'assigning deployment permissions') return attrs @@ -1451,6 +1481,56 @@ class JobCancelSerializer(JobSerializer): fields = ('can_cancel',) +class AdHocCommandSerializer(UnifiedJobSerializer): + + class Meta: + model = AdHocCommand + fields = ('*', 'job_type', 'inventory', 'limit', 'credential', + 'module_name', 'module_args', 'forks', 'verbosity', + 'privilege_escalation') + exclude = ('unified_job_template', 'name', 'description') + + def get_related(self, obj): + res = super(AdHocCommandSerializer, self).get_related(obj) + if obj.inventory and obj.inventory.active: + res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,)) + if obj.credential and obj.credential.active: + res['credential'] = reverse('api:credential_detail', args=(obj.credential.pk,)) + res.update(dict( + events = reverse('api:ad_hoc_command_ad_hoc_command_events_list', args=(obj.pk,)), + activity_stream = reverse('api:ad_hoc_command_activity_stream_list', args=(obj.pk,)), + )) + res['cancel'] = reverse('api:ad_hoc_command_cancel', args=(obj.pk,)) + res['relaunch'] = reverse('api:ad_hoc_command_relaunch', args=(obj.pk,)) + return res + + def to_native(self, obj): + # In raw data form, populate limit field from host/group name. + view = self.context.get('view', None) + parent_model = getattr(view, 'parent_model', None) + if not (obj and obj.pk) and view and hasattr(view, '_raw_data_form_marker'): + if not obj: + obj = self.opts.model() + if parent_model in (Host, Group): + parent_obj = parent_model.objects.get(pk=view.kwargs['pk']) + obj.limit = parent_obj.name + ret = super(AdHocCommandSerializer, self).to_native(obj) + # Hide inventory field from raw data, since it will be set automatically + # by sub list create view. + if not (obj and obj.pk) and view and hasattr(view, '_raw_data_form_marker'): + if parent_model in (Host, Group): + ret.pop('inventory', None) + return ret + + +class AdHocCommandCancelSerializer(AdHocCommandSerializer): + + can_cancel = serializers.BooleanField(source='can_cancel', read_only=True) + + class Meta: + fields = ('can_cancel',) + + class SystemJobTemplateSerializer(UnifiedJobTemplateSerializer): class Meta: @@ -1482,6 +1562,9 @@ class SystemJobSerializer(UnifiedJobSerializer): class JobListSerializer(JobSerializer, UnifiedJobListSerializer): pass +class AdHocCommandListSerializer(AdHocCommandSerializer, UnifiedJobListSerializer): + pass + class SystemJobListSerializer(SystemJobSerializer, UnifiedJobListSerializer): pass @@ -1548,6 +1631,27 @@ class JobEventSerializer(BaseSerializer): pass return d + +class AdHocCommandEventSerializer(BaseSerializer): + + event_display = serializers.Field(source='get_event_display') + + class Meta: + model = AdHocCommandEvent + fields = ('*', '-name', '-description', 'ad_hoc_command', 'event', + 'counter', 'event_display', 'event_data', 'failed', + 'changed', 'host', 'host_name') + + def get_related(self, obj): + res = super(AdHocCommandEventSerializer, self).get_related(obj) + res.update(dict( + ad_hoc_command = reverse('api:ad_hoc_command_detail', args=(obj.ad_hoc_command_id,)), + )) + if obj.host: + res['host'] = reverse('api:host_detail', args=(obj.host.pk,)) + return res + + class ScheduleSerializer(BaseSerializer): class Meta: diff --git a/awx/api/templates/api/sub_list_create_api_view.md b/awx/api/templates/api/sub_list_create_api_view.md index c313614f4e..23677f649b 100644 --- a/awx/api/templates/api/sub_list_create_api_view.md +++ b/awx/api/templates/api/sub_list_create_api_view.md @@ -12,6 +12,7 @@ fields to create a new {{ model_verbose_name }} associated with this {% block post_create %}{% endblock %} +{% if view.attach %} {% if parent_key %} # Remove {{ parent_model_verbose_name|title }} {{ model_verbose_name_plural|title }}: @@ -35,5 +36,6 @@ Make a POST request to this resource with `id` and `disassociate` fields to remove the {{ model_verbose_name }} from this {{ parent_model_verbose_name }} without deleting the {{ model_verbose_name }}. {% endif %} +{% endif %} {% include "api/_new_in_awx.md" %} diff --git a/awx/api/urls.py b/awx/api/urls.py index 01ffd69db2..16d5ddb2ea 100644 --- a/awx/api/urls.py +++ b/awx/api/urls.py @@ -74,6 +74,7 @@ inventory_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/inventory_sources/$', 'inventory_inventory_sources_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'inventory_activity_stream_list'), url(r'^(?P[0-9]+)/scan_job_templates/$', 'inventory_scan_job_template_list'), + url(r'^(?P[0-9]+)/ad_hoc_commands/$', 'inventory_ad_hoc_commands_list'), ) host_urls = patterns('awx.api.views', @@ -86,6 +87,8 @@ host_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/job_host_summaries/$', 'host_job_host_summaries_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'host_activity_stream_list'), url(r'^(?P[0-9]+)/inventory_sources/$', 'host_inventory_sources_list'), + url(r'^(?P[0-9]+)/ad_hoc_commands/$', 'host_ad_hoc_commands_list'), + url(r'^(?P[0-9]+)/ad_hoc_command_events/$', 'host_ad_hoc_command_events_list'), ) group_urls = patterns('awx.api.views', @@ -100,6 +103,7 @@ group_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/potential_children/$', 'group_potential_children_list'), url(r'^(?P[0-9]+)/activity_stream/$', 'group_activity_stream_list'), url(r'^(?P[0-9]+)/inventory_sources/$', 'group_inventory_sources_list'), + url(r'^(?P[0-9]+)/ad_hoc_commands/$', 'group_ad_hoc_commands_list'), ) inventory_source_urls = patterns('awx.api.views', @@ -171,6 +175,21 @@ job_event_urls = patterns('awx.api.views', url(r'^(?P[0-9]+)/hosts/$', 'job_event_hosts_list'), ) +ad_hoc_command_urls = patterns('awx.api.views', + url(r'^$', 'ad_hoc_command_list'), + url(r'^(?P[0-9]+)/$', 'ad_hoc_command_detail'), + url(r'^(?P[0-9]+)/cancel/$', 'ad_hoc_command_cancel'), + url(r'^(?P[0-9]+)/relaunch/$', 'ad_hoc_command_relaunch'), + url(r'^(?P[0-9]+)/events/$', 'ad_hoc_command_ad_hoc_command_events_list'), + url(r'^(?P[0-9]+)/activity_stream/$', 'ad_hoc_command_activity_stream_list'), + url(r'^(?P[0-9]+)/stdout/$', 'ad_hoc_command_stdout'), +) + +ad_hoc_command_event_urls = patterns('awx.api.views', + url(r'^$', 'ad_hoc_command_event_list'), + url(r'^(?P[0-9]+)/$', 'ad_hoc_command_event_detail'), +) + system_job_template_urls = patterns('awx.api.views', url(r'^$', 'system_job_template_list'), url(r'^(?P[0-9]+)/$', 'system_job_template_detail'), @@ -222,6 +241,8 @@ v1_urls = patterns('awx.api.views', url(r'^jobs/', include(job_urls)), url(r'^job_host_summaries/', include(job_host_summary_urls)), url(r'^job_events/', include(job_event_urls)), + url(r'^ad_hoc_commands/', include(ad_hoc_command_urls)), + url(r'^ad_hoc_command_events/', include(ad_hoc_command_event_urls)), url(r'^system_job_templates/', include(system_job_template_urls)), url(r'^system_jobs/', include(system_job_urls)), url(r'^unified_job_templates/$', 'unified_job_template_list'), diff --git a/awx/api/views.py b/awx/api/views.py index 660ccce4d8..f1e5c6b008 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -44,7 +44,7 @@ import ansiconv from awx.main.task_engine import TaskSerializer, TASK_FILE from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment -from awx.api.authentication import JobTaskAuthentication +from awx.api.authentication import TaskAuthentication from awx.api.utils.decorators import paginated from awx.api.generics import get_view_name from awx.api.generics import * # noqa @@ -111,6 +111,7 @@ class ApiV1RootView(APIView): data['hosts'] = reverse('api:host_list') data['job_templates'] = reverse('api:job_template_list') data['jobs'] = reverse('api:job_list') + data['ad_hoc_commands'] = reverse('api:ad_hoc_command_list') data['system_job_templates'] = reverse('api:system_job_template_list') data['system_jobs'] = reverse('api:system_job_list') data['schedules'] = reverse('api:schedule_list') @@ -492,28 +493,28 @@ class OrganizationInventoriesList(SubListAPIView): parent_model = Organization relationship = 'inventories' -class OrganizationUsersList(SubListCreateAPIView): +class OrganizationUsersList(SubListCreateAttachDetachAPIView): model = User serializer_class = UserSerializer parent_model = Organization relationship = 'users' -class OrganizationAdminsList(SubListCreateAPIView): +class OrganizationAdminsList(SubListCreateAttachDetachAPIView): model = User serializer_class = UserSerializer parent_model = Organization relationship = 'admins' -class OrganizationProjectsList(SubListCreateAPIView): +class OrganizationProjectsList(SubListCreateAttachDetachAPIView): model = Project serializer_class = ProjectSerializer parent_model = Organization relationship = 'projects' -class OrganizationTeamsList(SubListCreateAPIView): +class OrganizationTeamsList(SubListCreateAttachDetachAPIView): model = Team serializer_class = TeamSerializer @@ -539,14 +540,14 @@ class TeamDetail(RetrieveUpdateDestroyAPIView): model = Team serializer_class = TeamSerializer -class TeamUsersList(SubListCreateAPIView): +class TeamUsersList(SubListCreateAttachDetachAPIView): model = User serializer_class = UserSerializer parent_model = Team relationship = 'users' -class TeamPermissionsList(SubListCreateAPIView): +class TeamPermissionsList(SubListCreateAttachDetachAPIView): model = Permission serializer_class = PermissionSerializer @@ -565,14 +566,14 @@ class TeamPermissionsList(SubListCreateAPIView): return base raise PermissionDenied() -class TeamProjectsList(SubListCreateAPIView): +class TeamProjectsList(SubListCreateAttachDetachAPIView): model = Project serializer_class = ProjectSerializer parent_model = Team relationship = 'projects' -class TeamCredentialsList(SubListCreateAPIView): +class TeamCredentialsList(SubListCreateAttachDetachAPIView): model = Credential serializer_class = CredentialSerializer @@ -631,21 +632,21 @@ class ProjectPlaybooks(RetrieveAPIView): model = Project serializer_class = ProjectPlaybooksSerializer -class ProjectOrganizationsList(SubListCreateAPIView): +class ProjectOrganizationsList(SubListCreateAttachDetachAPIView): model = Organization serializer_class = OrganizationSerializer parent_model = Project relationship = 'organizations' -class ProjectTeamsList(SubListCreateAPIView): +class ProjectTeamsList(SubListCreateAttachDetachAPIView): model = Team serializer_class = TeamSerializer parent_model = Project relationship = 'teams' -class ProjectSchedulesList(SubListCreateAPIView): +class ProjectSchedulesList(SubListCreateAttachDetachAPIView): view_name = "Project Schedules" @@ -746,7 +747,7 @@ class UserTeamsList(SubListAPIView): parent_model = User relationship = 'teams' -class UserPermissionsList(SubListCreateAPIView): +class UserPermissionsList(SubListCreateAttachDetachAPIView): model = Permission serializer_class = PermissionSerializer @@ -767,7 +768,7 @@ class UserProjectsList(SubListAPIView): qs = self.request.user.get_queryset(self.model) return qs.filter(teams__in=parent.teams.distinct()) -class UserCredentialsList(SubListCreateAPIView): +class UserCredentialsList(SubListCreateAttachDetachAPIView): model = Credential serializer_class = CredentialSerializer @@ -932,7 +933,7 @@ class HostDetail(RetrieveUpdateDestroyAPIView): model = Host serializer_class = HostSerializer -class InventoryHostsList(SubListCreateAPIView): +class InventoryHostsList(SubListCreateAttachDetachAPIView): model = Host serializer_class = HostSerializer @@ -940,7 +941,7 @@ class InventoryHostsList(SubListCreateAPIView): relationship = 'hosts' parent_key = 'inventory' -class HostGroupsList(SubListCreateAPIView): +class HostGroupsList(SubListCreateAttachDetachAPIView): ''' the list of groups a host is directly a member of ''' model = Group @@ -991,7 +992,7 @@ class GroupList(ListCreateAPIView): model = Group serializer_class = GroupSerializer -class GroupChildrenList(SubListCreateAPIView): +class GroupChildrenList(SubListCreateAttachDetachAPIView): model = Group serializer_class = GroupSerializer @@ -1050,7 +1051,7 @@ class GroupPotentialChildrenList(SubListAPIView): except_pks.update(parent.all_children.values_list('pk', flat=True)) return qs.exclude(pk__in=except_pks) -class GroupHostsList(SubListCreateAPIView): +class GroupHostsList(SubListCreateAttachDetachAPIView): ''' the list of hosts directly below a group ''' model = Host @@ -1124,7 +1125,7 @@ class GroupDetail(RetrieveUpdateDestroyAPIView): obj.mark_inactive_recursive() return Response(status=status.HTTP_204_NO_CONTENT) -class InventoryGroupsList(SubListCreateAPIView): +class InventoryGroupsList(SubListCreateAttachDetachAPIView): model = Group serializer_class = GroupSerializer @@ -1132,7 +1133,7 @@ class InventoryGroupsList(SubListCreateAPIView): relationship = 'groups' parent_key = 'inventory' -class InventoryRootGroupsList(SubListCreateAPIView): +class InventoryRootGroupsList(SubListCreateAttachDetachAPIView): model = Group serializer_class = GroupSerializer @@ -1171,8 +1172,8 @@ class InventoryScriptView(RetrieveAPIView): model = Inventory serializer_class = InventoryScriptSerializer - authentication_classes = [JobTaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES - permission_classes = (JobTaskPermission,) + authentication_classes = [TaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES + permission_classes = (TaskPermission,) filter_backends = () def retrieve(self, request, *args, **kwargs): @@ -1330,7 +1331,7 @@ class InventorySourceDetail(RetrieveUpdateAPIView): pu.cancel() return super(InventorySourceDetail, self).destroy(request, *args, **kwargs) -class InventorySourceSchedulesList(SubListCreateAPIView): +class InventorySourceSchedulesList(SubListCreateAttachDetachAPIView): view_name = "Inventory Source Schedules" @@ -1479,7 +1480,7 @@ class JobTemplateLaunch(GenericAPIView): data = dict(job=new_job.id) return Response(data, status=status.HTTP_202_ACCEPTED) -class JobTemplateSchedulesList(SubListCreateAPIView): +class JobTemplateSchedulesList(SubListCreateAttachDetachAPIView): view_name = "Job Template Schedules" @@ -1749,7 +1750,7 @@ class SystemJobTemplateLaunch(GenericAPIView): data = dict(system_job=new_job.id) return Response(data, status=status.HTTP_202_ACCEPTED) -class SystemJobTemplateSchedulesList(SubListCreateAPIView): +class SystemJobTemplateSchedulesList(SubListCreateAttachDetachAPIView): view_name = "System Job Template Schedules" @@ -1944,8 +1945,8 @@ class GroupJobEventsList(BaseJobEventsList): class JobJobEventsList(BaseJobEventsList): parent_model = Job - authentication_classes = [JobTaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES - permission_classes = (JobTaskPermission,) + authentication_classes = [TaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES + permission_classes = (TaskPermission,) # Post allowed for job event callback only. def post(self, request, *args, **kwargs): @@ -1966,8 +1967,6 @@ class JobJobPlaysList(BaseJobEventsList): parent_model = Job view_name = 'Job Plays List' - authentication_classes = [JobTaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES - permission_classes = (JobTaskPermission,) new_in_200 = True @paginated @@ -2042,8 +2041,6 @@ class JobJobTasksList(BaseJobEventsList): and their completion status. """ parent_model = Job - authentication_classes = [JobTaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES - permission_classes = (JobTaskPermission,) view_name = 'Job Play Tasks List' new_in_200 = True @@ -2175,6 +2172,174 @@ class JobJobTasksList(BaseJobEventsList): # Done; return the results and count. return (results, count, None) + +class AdHocCommandList(ListCreateAPIView): + + model = AdHocCommand + serializer_class = AdHocCommandListSerializer + new_in_220 = True + + def create(self, request, *args, **kwargs): + # Inject inventory ID if parent objects is a host/group. + if hasattr(self, 'get_parent_object') and not getattr(self, 'parent_key', None): + data = request.DATA + # HACK: Make request data mutable. + if getattr(data, '_mutable', None) is False: + data._mutable = True + parent_obj = self.get_parent_object() + if isinstance(parent_obj, (Host, Group)): + data['inventory'] = parent_obj.inventory_id + response = super(AdHocCommandList, self).create(request, *args, **kwargs) + if response.status_code != status.HTTP_201_CREATED: + return response + # Start ad hoc command running when created. + ad_hoc_command = get_object_or_400(self.model, pk=response.data['id']) + result = ad_hoc_command.signal_start(**request.DATA) + if not result: + data = dict(passwords_needed_to_start=ad_hoc_command.passwords_needed_to_start) + return Response(data, status=status.HTTP_400_BAD_REQUEST) + return response + + +class InventoryAdHocCommandsList(AdHocCommandList, SubListCreateAPIView): + + parent_model = Inventory + relationship = 'ad_hoc_commands' + parent_key = 'inventory' + + +class GroupAdHocCommandsList(AdHocCommandList, SubListCreateAPIView): + + parent_model = Group + relationship = 'ad_hoc_commands' + + +class HostAdHocCommandsList(AdHocCommandList, SubListCreateAPIView): + + parent_model = Host + relationship = 'ad_hoc_commands' + + +class AdHocCommandDetail(RetrieveAPIView): + + model = AdHocCommand + serializer_class = AdHocCommandSerializer + new_in_220 = True + + +class AdHocCommandCancel(RetrieveAPIView): + + model = AdHocCommand + serializer_class = AdHocCommandCancelSerializer + is_job_cancel = True + new_in_220 = True + + def post(self, request, *args, **kwargs): + obj = self.get_object() + if obj.can_cancel: + obj.cancel() + return Response(status=status.HTTP_202_ACCEPTED) + else: + return self.http_method_not_allowed(request, *args, **kwargs) + + +class AdHocCommandRelaunch(GenericAPIView): + + model = AdHocCommand + is_job_start = True + new_in_220 = True + # FIXME: Add serializer class to define fields in OPTIONS request! + + @csrf_exempt + @transaction.non_atomic_requests + def dispatch(self, *args, **kwargs): + return super(AdHocCommandRelaunch, self).dispatch(*args, **kwargs) + + def get(self, request, *args, **kwargs): + obj = self.get_object() + data = {} + data['passwords_needed_to_start'] = obj.passwords_needed_to_start + return Response(data) + + def post(self, request, *args, **kwargs): + obj = self.get_object() + if not request.user.can_access(self.model, 'start', obj): + raise PermissionDenied() + new_ad_hoc_command = obj.copy() + result = new_ad_hoc_command.signal_start(**request.DATA) + if not result: + data = dict(passwords_needed_to_start=obj.passwords_needed_to_start) + return Response(data, status=status.HTTP_400_BAD_REQUEST) + else: + data = dict(ad_hoc_command=new_ad_hoc_command.id) + return Response(data, status=status.HTTP_202_ACCEPTED) + + +class AdHocCommandEventList(ListAPIView): + + model = AdHocCommandEvent + serializer_class = AdHocCommandEventSerializer + new_in_220 = True + + +class AdHocCommandEventDetail(RetrieveAPIView): + + model = AdHocCommandEvent + serializer_class = AdHocCommandEventSerializer + new_in_220 = True + + +class BaseAdHocCommandEventsList(SubListAPIView): + + model = AdHocCommandEvent + serializer_class = AdHocCommandEventSerializer + parent_model = None # Subclasses must define this attribute. + relationship = 'ad_hoc_command_events' + view_name = 'Ad Hoc Command Events List' + new_in_220 = True + + +class HostAdHocCommandEventsList(BaseAdHocCommandEventsList): + + parent_model = Host + new_in_220 = True + +#class GroupJobEventsList(BaseJobEventsList): +# parent_model = Group + + +class AdHocCommandAdHocCommandEventsList(BaseAdHocCommandEventsList): + + parent_model = AdHocCommand + authentication_classes = [TaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES + permission_classes = (TaskPermission,) + new_in_220 = True + + # Post allowed for ad hoc event callback only. + def post(self, request, *args, **kwargs): + parent_obj = get_object_or_404(self.parent_model, pk=self.kwargs['pk']) + data = request.DATA.copy() + data['ad_hoc_command'] = parent_obj.pk + serializer = self.get_serializer(data=data) + if serializer.is_valid(): + self.pre_save(serializer.object) + self.object = serializer.save(force_insert=True) + self.post_save(self.object, created=True) + headers = {'Location': serializer.data['url']} + return Response(serializer.data, status=status.HTTP_201_CREATED, + headers=headers) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class AdHocCommandActivityStreamList(SubListAPIView): + + model = ActivityStream + serializer_class = ActivityStreamSerializer + parent_model = AdHocCommand + relationship = 'activitystream_set' + new_in_220 = True + + class SystemJobList(ListCreateAPIView): model = SystemJob @@ -2254,6 +2419,11 @@ class JobStdout(UnifiedJobStdout): model = Job +class AdHocCommandStdout(UnifiedJobStdout): + + model = AdHocCommand + new_in_220 = True + class ActivityStreamList(SimpleListAPIView): model = ActivityStream diff --git a/awx/main/access.py b/awx/main/access.py index f476002a8e..328a983c91 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -152,6 +152,22 @@ class BaseAccess(object): def can_unattach(self, obj, sub_obj, relationship): return self.can_change(obj, None) + def check_license(self): + reader = TaskSerializer() + validation_info = reader.from_file() + if 'test' in sys.argv or 'jenkins' in sys.argv: + validation_info['free_instances'] = 99999999 + validation_info['time_remaining'] = 99999999 + validation_info['grace_period_remaining'] = 99999999 + + if validation_info.get('time_remaining', None) is None: + raise PermissionDenied("license is missing") + if validation_info.get("grace_period_remaining") <= 0: + raise PermissionDenied("license has expired") + if validation_info.get('free_instances', 0) < 0: + #raise PermissionDenied("Host Count exceeds available instances") + raise PermissionDenied("license range of %s instances has been exceeded" % validation_info.get('available_instances', 0)) + class UserAccess(BaseAccess): ''' I can see user records when: @@ -255,6 +271,10 @@ class InventoryAccess(BaseAccess): - I'm a superuser. - I'm an org admin of the inventory's org. - I have admin permissions on it. + I can run ad hoc commands when: + - I'm a superuser. + - I'm an org admin of the inventory's org. + - I have read/write/admin permission on an inventory with the run_ad_hoc_commands flag set. ''' model = Inventory @@ -327,6 +347,18 @@ class InventoryAccess(BaseAccess): def can_delete(self, obj): return self.can_admin(obj, None) + def can_run_ad_hoc_commands(self, obj): + qs = self.get_queryset(PERMISSION_TYPES_ALLOWING_INVENTORY_READ) + if not obj or not qs.filter(pk=obj.pk).exists(): + return False + if self.user.is_superuser: + return True + if self.user in obj.organization.admins.all(): + return True + if qs.filter(pk=obj.pk, permissions__permission_type__in=PERMISSION_TYPES_ALLOWING_INVENTORY_READ, permissions__run_ad_hoc_commands=True).exists(): + return True + return False + class HostAccess(BaseAccess): ''' I can see hosts whenever I can see their inventory. @@ -358,25 +390,8 @@ class HostAccess(BaseAccess): return False # Check to see if we have enough licenses - reader = TaskSerializer() - validation_info = reader.from_file() - - if 'test' in sys.argv or 'jenkins' in sys.argv: - # this hack is in here so the test code can function - # but still go down *most* of the license code path. - validation_info['free_instances'] = 99999999 - validation_info['time_remaining'] = 99999999 - validation_info['grace_period_remaining'] = 99999999 - - if validation_info.get('time_remaining', None) is None: - raise PermissionDenied("license is missing") - if validation_info.get('grace_period_remaining') <= 0: - raise PermissionDenied("license has expired") - - if validation_info.get('free_instances', 0) > 0: - return True - instances = validation_info.get('available_instances', 0) - raise PermissionDenied("license range of %s instances has been exceeded" % instances) + self.check_license() + return True def can_change(self, obj, data): # Prevent moving a host to a different inventory. @@ -972,21 +987,9 @@ class JobTemplateAccess(BaseAccess): # return False def can_start(self, obj, validate_license=True): - reader = TaskSerializer() - validation_info = reader.from_file() - + # Check license. if validate_license: - if 'test' in sys.argv or 'jenkins' in sys.argv: - validation_info['free_instances'] = 99999999 - validation_info['time_remaining'] = 99999999 - validation_info['grace_period_remaining'] = 99999999 - - if validation_info.get('time_remaining', None) is None: - raise PermissionDenied("license is missing") - if validation_info.get("grace_period_remaining") <= 0: - raise PermissionDenied("license has expired") - if validation_info.get('free_instances', 0) < 0: - raise PermissionDenied("Host Count exceeds available instances") + self.check_license() # Super users can start any job if self.user.is_superuser: @@ -1105,19 +1108,6 @@ class JobAccess(BaseAccess): if not self.user.is_superuser: return False - reader = TaskSerializer() - validation_info = reader.from_file() - if 'test' in sys.argv or 'jenkins' in sys.argv: - validation_info['free_instances'] = 99999999 - validation_info['time_remaining'] = 99999999 - validation_info['grace_period_remaining'] = 99999999 - - if validation_info.get('time_remaining', None) is None: - raise PermissionDenied("license is missing") - if validation_info.get("grace_period_remaining") <= 0: - raise PermissionDenied("license has expired") - if validation_info.get('free_instances', 0) < 0: - raise PermissionDenied("Host Count exceeds available instances") add_data = dict(data.items()) @@ -1142,20 +1132,7 @@ class JobAccess(BaseAccess): return self.can_read(obj) def can_start(self, obj): - reader = TaskSerializer() - validation_info = reader.from_file() - - if 'test' in sys.argv or 'jenkins' in sys.argv: - validation_info['free_instances'] = 99999999 - validation_info['time_remaining'] = 99999999 - validation_info['grace_period_remaining'] = 99999999 - - if validation_info.get('time_remaining', None) is None: - raise PermissionDenied("license is missing") - if validation_info.get("grace_period_remaining") <= 0: - raise PermissionDenied("license has expired") - if validation_info.get('free_instances', 0) < 0: - raise PermissionDenied("Host Count exceeds available instances") + self.check_license() # A super user can relaunch a job if self.user.is_superuser: @@ -1188,6 +1165,102 @@ class SystemJobAccess(BaseAccess): ''' model = SystemJob +class AdHocCommandAccess(BaseAccess): + ''' + I can only see/run ad hoc commands when: + - I am a superuser. + - I am an org admin and have permission to read the credential. + - I am a normal user with a user/team permission that has at least read + permission on the inventory and the run_ad_hoc_commands flag set, and I + can read the credential. + ''' + model = AdHocCommand + + def get_queryset(self): + qs = self.model.objects.filter(active=True).distinct() + qs = qs.select_related('created_by', 'modified_by', 'inventory', + 'credential') + if self.user.is_superuser: + return qs + + credential_ids = set(self.user.get_queryset(Credential).values_list('id', flat=True)) + team_ids = set(Team.objects.filter(active=True, users__in=[self.user]).values_list('id', flat=True)) + + permission_ids = set(Permission.objects.filter( + Q(user=self.user) | Q(team__in=team_ids), + active=True, + permission_type__in=PERMISSION_TYPES_ALLOWING_INVENTORY_READ, + run_ad_hoc_commands=True, + ).values_list('id', flat=True)) + + inventory_qs = self.user.get_queryset(Inventory) + inventory_qs = inventory_qs.filter(Q(permissions__in=permission_ids) | Q(organization__admins__in=[self.user])) + inventory_ids = set(inventory_qs.values_list('id', flat=True)) + + qs = qs.filter( + credential_id__in=credential_ids, + inventory_id__in=inventory_ids, + ) + return qs + + def can_add(self, data): + if not data or '_method' in data: # So the browseable API will work? + return True + + self.check_license() + + # If a credential is provided, the user should have read access to it. + credential_pk = get_pk_from_dict(data, 'credential') + if credential_pk: + credential = get_object_or_400(Credential, pk=credential_pk) + if not self.user.can_access(Credential, 'read', credential): + return False + + # Check that the user has the run ad hoc command permission on the + # given inventory. + inventory_pk = get_pk_from_dict(data, 'inventory') + if inventory_pk: + inventory = get_object_or_400(Inventory, pk=inventory_pk) + if not self.user.can_access(Inventory, 'run_ad_hoc_commands', inventory): + return False + + return True + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + +class AdHocCommandEventAccess(BaseAccess): + ''' + I can see ad hoc command event records whenever I can read both ad hoc + command and host. + ''' + + model = AdHocCommandEvent + + def get_queryset(self): + qs = self.model.objects.distinct() + qs = qs.select_related('created_by', 'modified_by', 'ad_hoc_command', 'host') + + if self.user.is_superuser: + return qs + ad_hoc_command_qs = self.user.get_queryset(AdHocCommand) + host_qs = self.user.get_queryset(Host) + qs = qs.filter(Q(host__isnull=True) | Q(host__in=host_qs), + ad_hoc_command__in=ad_hoc_command_qs) + return qs + + def can_add(self, data): + return False + + def can_change(self, obj, data): + return False + + def can_delete(self, obj): + return False + class JobHostSummaryAccess(BaseAccess): ''' I can see job/host summary records whenever I can read both job and host. @@ -1293,10 +1366,12 @@ class UnifiedJobAccess(BaseAccess): project_update_qs = self.user.get_queryset(ProjectUpdate) inventory_update_qs = self.user.get_queryset(InventoryUpdate).filter(source__in=CLOUD_INVENTORY_SOURCES) job_qs = self.user.get_queryset(Job) + ad_hoc_command_qs = self.user.get_queryset(AdHocCommand) system_job_qs = self.user.get_queryset(SystemJob) qs = qs.filter(Q(ProjectUpdate___in=project_update_qs) | Q(InventoryUpdate___in=inventory_update_qs) | Q(Job___in=job_qs) | + Q(AdHocCommand___in=ad_hoc_command_qs) | Q(SystemJob___in=system_job_qs)) qs = qs.select_related( 'created_by', @@ -1537,6 +1612,8 @@ register_access(JobHostSummary, JobHostSummaryAccess) register_access(JobEvent, JobEventAccess) register_access(SystemJobTemplate, SystemJobTemplateAccess) register_access(SystemJob, SystemJobAccess) +register_access(AdHocCommand, AdHocCommandAccess) +register_access(AdHocCommandEvent, AdHocCommandEventAccess) register_access(Schedule, ScheduleAccess) register_access(UnifiedJobTemplate, UnifiedJobTemplateAccess) register_access(UnifiedJob, UnifiedJobAccess) diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index 595e1f850e..8205e23685 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -117,7 +117,9 @@ class CallbackReceiver(object): with Socket('callbacks', 'r') as callbacks: for message in callbacks.listen(): total_messages += 1 - if not use_workers: + if 'ad_hoc_command_id' in message: + self.process_ad_hoc_event(message) + elif not use_workers: self.process_job_event(message) else: job_parent_events = last_parent_events.get(message['job_id'], {}) @@ -216,10 +218,68 @@ class CallbackReceiver(object): # Retrun the job event object. return job_event except DatabaseError as e: - # Log the error and try again. + # Log the error and bail out. logger.error('Database error saving job event: %s', e) return None + @transaction.atomic + def process_ad_hoc_event(self, data): + # Sanity check: Do we need to do anything at all? + event = data.get('event', '') + if not event or 'ad_hoc_command_id' not in data: + return + + # Get the correct "verbose" value from the job. + # If for any reason there's a problem, just use 0. + try: + verbose = AdHocCommand.objects.get(id=data['ad_hoc_command_id']).verbosity + except Exception, e: + verbose = 0 + + # Convert the datetime for the job event's creation appropriately, + # and include a time zone for it. + # + # In the event of any issue, throw it out, and Django will just save + # the current time. + try: + if not isinstance(data['created'], datetime.datetime): + data['created'] = parse_datetime(data['created']) + if not data['created'].tzinfo: + data['created'] = data['created'].replace(tzinfo=FixedOffset(0)) + except (KeyError, ValueError): + data.pop('created', None) + + # Print the data to stdout if we're in DEBUG mode. + if settings.DEBUG: + print data + + # Sanity check: Don't honor keys that we don't recognize. + for key in data.keys(): + if key not in ('ad_hoc_command_id', 'event', 'event_data', + 'created', 'counter'): + data.pop(key) + + # Save any modifications to the ad hoc command event to the database. + # If we get a database error of some kind, bail out. + try: + # If we're not in verbose mode, wipe out any module + # arguments. FIXME: Needed for adhoc? + res = data['event_data'].get('res', {}) + if isinstance(res, dict): + i = res.get('invocation', {}) + if verbose == 0 and 'module_args' in i: + i['module_args'] = '' + + # Create a new AdHocCommandEvent object. + ad_hoc_command_event = AdHocCommandEvent.objects.create(**data) + + # Retrun the ad hoc comamnd event object. + return ad_hoc_command_event + except DatabaseError as e: + # Log the error and bail out. + logger.error('Database error saving ad hoc command event: %s', e) + return None + def callback_worker(self, queue_actual, idx): messages_processed = 0 while True: diff --git a/awx/main/management/commands/run_socketio_service.py b/awx/main/management/commands/run_socketio_service.py index d50f3aee2b..3454c2658b 100644 --- a/awx/main/management/commands/run_socketio_service.py +++ b/awx/main/management/commands/run_socketio_service.py @@ -90,6 +90,12 @@ class JobEventNamespace(TowerBaseNamespace): logger.info("Received client connect for job event namespace from %s" % str(self.environ['REMOTE_ADDR'])) super(JobEventNamespace, self).recv_connect() +class AdHocCommandEventNamespace(TowerBaseNamespace): + + def recv_connect(self): + logger.info("Received client connect for ad hoc command event namespace from %s" % str(self.environ['REMOTE_ADDR'])) + super(AdHocCommandEventNamespace, self).recv_connect() + class ScheduleNamespace(TowerBaseNamespace): def get_allowed_methods(self): @@ -107,6 +113,7 @@ class TowerSocket(object): socketio_manage(environ, {'/socket.io/test': TestNamespace, '/socket.io/jobs': JobNamespace, '/socket.io/job_events': JobEventNamespace, + '/socket.io/ad_hoc_command_events': AdHocCommandEventNamespace, '/socket.io/schedules': ScheduleNamespace}) else: logger.warn("Invalid connect path received: " + path) diff --git a/awx/main/management/commands/run_task_system.py b/awx/main/management/commands/run_task_system.py index d7918e58f5..f1f32bb590 100644 --- a/awx/main/management/commands/run_task_system.py +++ b/awx/main/management/commands/run_task_system.py @@ -48,6 +48,8 @@ class SimpleDAG(object): def short_string_obj(obj): if type(obj) == Job: type_str = "Job" + if type(obj) == AdHocCommand: + type_str = "AdHocCommand" elif type(obj) == InventoryUpdate: type_str = "Inventory" elif type(obj) == ProjectUpdate: @@ -100,6 +102,8 @@ class SimpleDAG(object): def get_node_type(self, obj): if type(obj) == Job: return "job" + elif type(obj) == AdHocCommand: + return "ad_hoc_command" elif type(obj) == InventoryUpdate: return "inventory_update" elif type(obj) == ProjectUpdate: @@ -136,13 +140,14 @@ def get_tasks(): RELEVANT_JOBS = ('pending', 'waiting', 'running') # TODO: Replace this when we can grab all objects in a sane way. graph_jobs = [j for j in Job.objects.filter(status__in=RELEVANT_JOBS)] + graph_ad_hoc_commands = [ahc for ahc in AdHocCommand.objects.filter(status__in=RELEVANT_JOBS)] graph_inventory_updates = [iu for iu in InventoryUpdate.objects.filter(status__in=RELEVANT_JOBS)] graph_project_updates = [pu for pu in ProjectUpdate.objects.filter(status__in=RELEVANT_JOBS)] graph_system_jobs = [sj for sj in SystemJob.objects.filter(status__in=RELEVANT_JOBS)] - all_actions = sorted(graph_jobs + graph_inventory_updates + + all_actions = sorted(graph_jobs + graph_ad_hoc_commands + graph_inventory_updates + graph_project_updates + graph_system_jobs, key=lambda task: task.created) return all_actions diff --git a/awx/main/migrations/0064_v220_changes.py b/awx/main/migrations/0064_v220_changes.py new file mode 100644 index 0000000000..a58c74ac6b --- /dev/null +++ b/awx/main/migrations/0064_v220_changes.py @@ -0,0 +1,575 @@ +# -*- coding: utf-8 -*- +from south.utils import datetime_utils as datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'AdHocCommand' + db.create_table(u'main_adhoccommand', ( + (u'unifiedjob_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['main.UnifiedJob'], unique=True, primary_key=True)), + ('job_type', self.gf('django.db.models.fields.CharField')(default='run', max_length=64)), + ('inventory', self.gf('django.db.models.fields.related.ForeignKey')(related_name='ad_hoc_commands', null=True, on_delete=models.SET_NULL, to=orm['main.Inventory'])), + ('limit', self.gf('django.db.models.fields.CharField')(default='', max_length=1024, blank=True)), + ('credential', self.gf('django.db.models.fields.related.ForeignKey')(default=None, related_name='ad_hoc_commands', null=True, on_delete=models.SET_NULL, to=orm['main.Credential'])), + ('module_name', self.gf('django.db.models.fields.CharField')(default='command', max_length=1024)), + ('module_args', self.gf('django.db.models.fields.TextField')(default='', blank=True)), + ('forks', self.gf('django.db.models.fields.PositiveIntegerField')(default=0, blank=True)), + ('verbosity', self.gf('django.db.models.fields.PositiveIntegerField')(default=0, blank=True)), + ('privilege_escalation', self.gf('django.db.models.fields.CharField')(default='', max_length=64, blank=True)), + )) + db.send_create_signal('main', ['AdHocCommand']) + + # Adding model 'AdHocCommandEvent' + db.create_table(u'main_adhoccommandevent', ( + (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(default=None)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(default=None)), + ('ad_hoc_command', self.gf('django.db.models.fields.related.ForeignKey')(related_name='ad_hoc_command_events', to=orm['main.AdHocCommand'])), + ('host', self.gf('django.db.models.fields.related.ForeignKey')(default=None, related_name='ad_hoc_command_events', null=True, on_delete=models.SET_NULL, to=orm['main.Host'])), + ('host_name', self.gf('django.db.models.fields.CharField')(default='', max_length=1024)), + ('event', self.gf('django.db.models.fields.CharField')(max_length=100)), + ('event_data', self.gf('jsonfield.fields.JSONField')(default={}, blank=True)), + ('failed', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('changed', self.gf('django.db.models.fields.BooleanField')(default=False)), + ('counter', self.gf('django.db.models.fields.PositiveIntegerField')(default=0)), + )) + db.send_create_signal('main', ['AdHocCommandEvent']) + + # Adding unique constraint on 'AdHocCommandEvent', fields ['ad_hoc_command', 'host_name'] + db.create_unique(u'main_adhoccommandevent', ['ad_hoc_command_id', 'host_name']) + + # Adding field 'Permission.run_ad_hoc_commands' + db.add_column(u'main_permission', 'run_ad_hoc_commands', + self.gf('django.db.models.fields.BooleanField')(default=False), + keep_default=False) + + # Adding M2M table for field ad_hoc_command on 'ActivityStream' + m2m_table_name = db.shorten_name(u'main_activitystream_ad_hoc_command') + db.create_table(m2m_table_name, ( + ('id', models.AutoField(verbose_name='ID', primary_key=True, auto_created=True)), + ('activitystream', models.ForeignKey(orm['main.activitystream'], null=False)), + ('adhoccommand', models.ForeignKey(orm['main.adhoccommand'], null=False)) + )) + db.create_unique(m2m_table_name, ['activitystream_id', 'adhoccommand_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'AdHocCommandEvent', fields ['ad_hoc_command', 'host_name'] + db.delete_unique(u'main_adhoccommandevent', ['ad_hoc_command_id', 'host_name']) + + # Deleting model 'AdHocCommand' + db.delete_table(u'main_adhoccommand') + + # Deleting model 'AdHocCommandEvent' + db.delete_table(u'main_adhoccommandevent') + + # Deleting field 'Permission.run_ad_hoc_commands' + db.delete_column(u'main_permission', 'run_ad_hoc_commands') + + # Removing M2M table for field ad_hoc_command on 'ActivityStream' + db.delete_table(db.shorten_name(u'main_activitystream_ad_hoc_command')) + + + models = { + u'auth.group': { + 'Meta': {'object_name': 'Group'}, + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + u'auth.permission': { + 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + u'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + u'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'main.activitystream': { + 'Meta': {'object_name': 'ActivityStream'}, + 'actor': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'activity_stream'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'ad_hoc_command': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.AdHocCommand']", 'symmetrical': 'False', 'blank': 'True'}), + 'changes': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'credential': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Credential']", 'symmetrical': 'False', 'blank': 'True'}), + 'custom_inventory_script': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.CustomInventoryScript']", 'symmetrical': 'False', 'blank': 'True'}), + 'group': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'host': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Host']", 'symmetrical': 'False', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Inventory']", 'symmetrical': 'False', 'blank': 'True'}), + 'inventory_source': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.InventorySource']", 'symmetrical': 'False', 'blank': 'True'}), + 'inventory_update': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.InventoryUpdate']", 'symmetrical': 'False', 'blank': 'True'}), + 'job': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Job']", 'symmetrical': 'False', 'blank': 'True'}), + 'job_template': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.JobTemplate']", 'symmetrical': 'False', 'blank': 'True'}), + 'object1': ('django.db.models.fields.TextField', [], {}), + 'object2': ('django.db.models.fields.TextField', [], {}), + 'object_relationship_type': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'operation': ('django.db.models.fields.CharField', [], {'max_length': '13'}), + 'organization': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Organization']", 'symmetrical': 'False', 'blank': 'True'}), + 'permission': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Project']", 'symmetrical': 'False', 'blank': 'True'}), + 'project_update': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.ProjectUpdate']", 'symmetrical': 'False', 'blank': 'True'}), + 'schedule': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Schedule']", 'symmetrical': 'False', 'blank': 'True'}), + 'team': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['main.Team']", 'symmetrical': 'False', 'blank': 'True'}), + 'timestamp': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'unified_job': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'activity_stream_as_unified_job+'", 'blank': 'True', 'to': "orm['main.UnifiedJob']"}), + 'unified_job_template': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'activity_stream_as_unified_job_template+'", 'blank': 'True', 'to': "orm['main.UnifiedJobTemplate']"}), + 'user': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.User']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'main.adhoccommand': { + 'Meta': {'object_name': 'AdHocCommand', '_ormbases': ['main.UnifiedJob']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'ad_hoc_commands'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Credential']"}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'ad_hoc_commands'", 'symmetrical': 'False', 'through': "orm['main.AdHocCommandEvent']", 'to': "orm['main.Host']"}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'ad_hoc_commands'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_type': ('django.db.models.fields.CharField', [], {'default': "'run'", 'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'module_args': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'module_name': ('django.db.models.fields.CharField', [], {'default': "'command'", 'max_length': '1024'}), + 'privilege_escalation': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '64', 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.adhoccommandevent': { + 'Meta': {'ordering': "('-pk',)", 'unique_together': "[('ad_hoc_command', 'host_name')]", 'object_name': 'AdHocCommandEvent'}, + 'ad_hoc_command': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'ad_hoc_command_events'", 'to': "orm['main.AdHocCommand']"}), + 'changed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'counter': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'event': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'event_data': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'ad_hoc_command_events'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Host']"}), + 'host_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}) + }, + 'main.authtoken': { + 'Meta': {'object_name': 'AuthToken'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'expires': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'key': ('django.db.models.fields.CharField', [], {'max_length': '40', 'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'request_hash': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '40', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'auth_tokens'", 'to': u"orm['auth.User']"}) + }, + 'main.credential': { + 'Meta': {'ordering': "('kind', 'name')", 'unique_together': "[('user', 'team', 'kind', 'name')]", 'object_name': 'Credential'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'cloud': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'credential\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'host': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'kind': ('django.db.models.fields.CharField', [], {'default': "'ssh'", 'max_length': '32'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'credential\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'ssh_key_data': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'ssh_key_unlock': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'su_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'su_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'sudo_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'sudo_username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'credentials'", 'null': 'True', 'blank': 'True', 'to': "orm['main.Team']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'credentials'", 'null': 'True', 'blank': 'True', 'to': u"orm['auth.User']"}), + 'username': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'vault_password': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}) + }, + 'main.custominventoryscript': { + 'Meta': {'ordering': "('name',)", 'unique_together': "[('name', 'organization')]", 'object_name': 'CustomInventoryScript'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'custominventoryscript\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'custominventoryscript\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'custom_inventory_scripts'", 'to': "orm['main.Organization']"}), + 'script': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.group': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('name', 'inventory'),)", 'object_name': 'Group'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'group\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'groups_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'has_inventory_sources': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'groups'", 'blank': 'True', 'to': "orm['main.Host']"}), + 'hosts_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'groups'", 'to': "orm['main.Inventory']"}), + 'inventory_sources': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'groups'", 'symmetrical': 'False', 'to': "orm['main.InventorySource']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'group\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'parents': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'children'", 'blank': 'True', 'to': "orm['main.Group']"}), + 'total_groups': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'total_hosts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.host': { + 'Meta': {'ordering': "('inventory', 'name')", 'unique_together': "(('name', 'inventory'),)", 'object_name': 'Host'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'host\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'has_inventory_sources': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'instance_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts'", 'to': "orm['main.Inventory']"}), + 'inventory_sources': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'hosts'", 'symmetrical': 'False', 'to': "orm['main.InventorySource']"}), + 'last_job': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'hosts_as_last_job+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Job']"}), + 'last_job_host_summary': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'hosts_as_last_job_summary+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobHostSummary']", 'blank': 'True', 'null': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'host\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.instance': { + 'Meta': {'object_name': 'Instance'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'hostname': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '250'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'primary': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '40'}) + }, + 'main.inventory': { + 'Meta': {'ordering': "('name',)", 'unique_together': "[('name', 'organization')]", 'object_name': 'Inventory'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'inventory\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'groups_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'has_active_failures': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'has_inventory_sources': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'hosts_with_active_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory_sources_with_failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'inventory\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventories'", 'to': "orm['main.Organization']"}), + 'total_groups': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'total_hosts': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'total_inventory_sources': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'variables': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}) + }, + 'main.inventorysource': { + 'Meta': {'object_name': 'InventorySource', '_ormbases': ['main.UnifiedJobTemplate']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventorysources'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'group': ('awx.main.fields.AutoOneToOneField', [], {'default': 'None', 'related_name': "'inventory_source'", 'unique': 'True', 'null': 'True', 'to': "orm['main.Group']"}), + 'group_by': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'instance_filters': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'inventory_sources'", 'null': 'True', 'to': "orm['main.Inventory']"}), + 'overwrite': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'overwrite_vars': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'source': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'source_path': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_regions': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_script': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['main.CustomInventoryScript']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}), + 'source_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}), + 'update_cache_timeout': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'update_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}) + }, + 'main.inventoryupdate': { + 'Meta': {'object_name': 'InventoryUpdate', '_ormbases': ['main.UnifiedJob']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventoryupdates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'group_by': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'instance_filters': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'inventory_source': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'inventory_updates'", 'to': "orm['main.InventorySource']"}), + 'license_error': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'overwrite': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'overwrite_vars': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'source': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'source_path': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_regions': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'source_script': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['main.CustomInventoryScript']", 'null': 'True', 'on_delete': 'models.SET_NULL', 'blank': 'True'}), + 'source_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.job': { + 'Meta': {'ordering': "('id',)", 'object_name': 'Job', '_ormbases': ['main.UnifiedJob']}, + 'cloud_credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs_as_cloud_credential+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'force_handlers': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'jobs'", 'symmetrical': 'False', 'through': "orm['main.JobHostSummary']", 'to': "orm['main.Host']"}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.JobTemplate']", 'blank': 'True', 'null': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'playbook': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Project']", 'blank': 'True', 'null': 'True'}), + 'skip_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'start_at_task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.jobevent': { + 'Meta': {'ordering': "('pk',)", 'object_name': 'JobEvent'}, + 'changed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'counter': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'event': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'event_data': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'job_events_as_primary_host'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Host']"}), + 'host_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'hosts': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'job_events'", 'symmetrical': 'False', 'to': "orm['main.Host']"}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_events'", 'to': "orm['main.Job']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'parent': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'children'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.JobEvent']"}), + 'play': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'role': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}) + }, + 'main.jobhostsummary': { + 'Meta': {'ordering': "('-pk',)", 'unique_together': "[('job', 'host_name')]", 'object_name': 'JobHostSummary'}, + 'changed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'dark': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'failures': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'host': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'job_host_summaries'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Host']"}), + 'host_name': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'job_host_summaries'", 'to': "orm['main.Job']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'ok': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'processed': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'skipped': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}) + }, + 'main.joborigin': { + 'Meta': {'object_name': 'JobOrigin'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'instance': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['main.Instance']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'unified_job': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'job_origin'", 'unique': 'True', 'to': "orm['main.UnifiedJob']"}) + }, + 'main.jobtemplate': { + 'Meta': {'ordering': "('name',)", 'object_name': 'JobTemplate', '_ormbases': ['main.UnifiedJobTemplate']}, + 'ask_variables_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'cloud_credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates_as_cloud_credential+'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'force_handlers': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'forks': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}), + 'host_config_key': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'job_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'limit': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'playbook': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobtemplates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Project']", 'blank': 'True', 'null': 'True'}), + 'skip_tags': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'start_at_task': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'survey_enabled': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'survey_spec': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}), + 'verbosity': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0', 'blank': 'True'}) + }, + 'main.organization': { + 'Meta': {'ordering': "('name',)", 'object_name': 'Organization'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'admins': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'admin_of_organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'organization\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'organization\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': "orm['main.Project']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'organizations'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.permission': { + 'Meta': {'object_name': 'Permission'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'permission\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'inventory': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Inventory']"}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'permission\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'permission_type': ('django.db.models.fields.CharField', [], {'max_length': '64'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Project']"}), + 'run_ad_hoc_commands': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'team': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Team']"}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'permissions'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}) + }, + 'main.profile': { + 'Meta': {'object_name': 'Profile'}, + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'ldap_dn': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'user': ('awx.main.fields.AutoOneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.project': { + 'Meta': {'ordering': "('id',)", 'object_name': 'Project', '_ormbases': ['main.UnifiedJobTemplate']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'projects'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'local_path': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'scm_branch': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'blank': 'True'}), + 'scm_clean': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_next_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '8', 'blank': 'True'}), + 'scm_update_cache_timeout': ('django.db.models.fields.PositiveIntegerField', [], {'default': '0'}), + 'scm_update_on_launch': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.projectupdate': { + 'Meta': {'object_name': 'ProjectUpdate', '_ormbases': ['main.UnifiedJob']}, + 'credential': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'projectupdates'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.Credential']", 'blank': 'True', 'null': 'True'}), + 'local_path': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'project': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'project_updates'", 'to': "orm['main.Project']"}), + 'scm_branch': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '256', 'blank': 'True'}), + 'scm_clean': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_delete_on_update': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'scm_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '8', 'blank': 'True'}), + 'scm_url': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.schedule': { + 'Meta': {'ordering': "['-next_run']", 'object_name': 'Schedule'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'schedule\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'dtend': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'dtstart': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'enabled': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'extra_data': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'schedule\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '512'}), + 'next_run': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'rrule': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'unified_job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'schedules'", 'to': "orm['main.UnifiedJobTemplate']"}) + }, + 'main.systemjob': { + 'Meta': {'ordering': "('id',)", 'object_name': 'SystemJob', '_ormbases': ['main.UnifiedJob']}, + 'extra_vars': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'job_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + 'system_job_template': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'jobs'", 'on_delete': 'models.SET_NULL', 'default': 'None', 'to': "orm['main.SystemJobTemplate']", 'blank': 'True', 'null': 'True'}), + u'unifiedjob_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJob']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.systemjobtemplate': { + 'Meta': {'object_name': 'SystemJobTemplate', '_ormbases': ['main.UnifiedJobTemplate']}, + 'job_type': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '32', 'blank': 'True'}), + u'unifiedjobtemplate_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['main.UnifiedJobTemplate']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'main.team': { + 'Meta': {'ordering': "('organization__name', 'name')", 'unique_together': "[('organization', 'name')]", 'object_name': 'Team'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'team\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'team\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'organization': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'teams'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Organization']"}), + 'projects': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': "orm['main.Project']"}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "'teams'", 'blank': 'True', 'to': u"orm['auth.User']"}) + }, + 'main.unifiedjob': { + 'Meta': {'object_name': 'UnifiedJob'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'cancel_flag': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'celery_task_id': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '100', 'blank': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjob\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'dependent_jobs': ('django.db.models.fields.related.ManyToManyField', [], {'related_name': "'dependent_jobs_rel_+'", 'to': "orm['main.UnifiedJob']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'elapsed': ('django.db.models.fields.DecimalField', [], {'max_digits': '12', 'decimal_places': '3'}), + 'failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'finished': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'job_args': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'job_cwd': ('django.db.models.fields.CharField', [], {'default': "''", 'max_length': '1024', 'blank': 'True'}), + 'job_env': ('jsonfield.fields.JSONField', [], {'default': '{}', 'blank': 'True'}), + 'job_explanation': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'launch_type': ('django.db.models.fields.CharField', [], {'default': "'manual'", 'max_length': '20'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjob\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'old_pk': ('django.db.models.fields.PositiveIntegerField', [], {'default': 'None', 'null': 'True'}), + 'polymorphic_ctype': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'polymorphic_main.unifiedjob_set'", 'null': 'True', 'to': u"orm['contenttypes.ContentType']"}), + 'result_stdout_file': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_stdout_text': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'result_traceback': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'schedule': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['main.Schedule']", 'null': 'True', 'on_delete': 'models.SET_NULL'}), + 'start_args': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'started': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'new'", 'max_length': '20'}), + 'unified_job_template': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjob_unified_jobs'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.UnifiedJobTemplate']"}) + }, + 'main.unifiedjobtemplate': { + 'Meta': {'unique_together': "[('polymorphic_ctype', 'name')]", 'object_name': 'UnifiedJobTemplate'}, + 'active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'created_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjobtemplate\', \'app_label\': \'main\'}(class)s_created+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'current_job': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjobtemplate_as_current_job+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.UnifiedJob']"}), + 'description': ('django.db.models.fields.TextField', [], {'default': "''", 'blank': 'True'}), + 'has_schedules': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_job': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjobtemplate_as_last_job+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.UnifiedJob']"}), + 'last_job_failed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_job_run': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'default': 'None'}), + 'modified_by': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': '"{\'class\': \'unifiedjobtemplate\', \'app_label\': \'main\'}(class)s_modified+"', 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': u"orm['auth.User']"}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '512'}), + 'next_job_run': ('django.db.models.fields.DateTimeField', [], {'default': 'None', 'null': 'True'}), + 'next_schedule': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'related_name': "'unifiedjobtemplate_as_next_schedule+'", 'null': 'True', 'on_delete': 'models.SET_NULL', 'to': "orm['main.Schedule']"}), + 'old_pk': ('django.db.models.fields.PositiveIntegerField', [], {'default': 'None', 'null': 'True'}), + 'polymorphic_ctype': ('django.db.models.fields.related.ForeignKey', [], {'related_name': "'polymorphic_main.unifiedjobtemplate_set'", 'null': 'True', 'to': u"orm['contenttypes.ContentType']"}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'ok'", 'max_length': '32'}) + } + } + + complete_apps = ['main'] \ No newline at end of file diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index d6ba4de96e..b7d57212de 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -12,6 +12,7 @@ from awx.main.models.credential import * # noqa from awx.main.models.projects import * # noqa from awx.main.models.inventory import * # noqa from awx.main.models.jobs import * # noqa +from awx.main.models.ad_hoc_commands import * # noqa from awx.main.models.schedules import * # noqa from awx.main.models.activity_stream import * # noqa from awx.main.models.ha import * # noqa @@ -51,6 +52,7 @@ activity_stream_registrar.connect(Project) activity_stream_registrar.connect(Permission) activity_stream_registrar.connect(JobTemplate) activity_stream_registrar.connect(Job) +activity_stream_registrar.connect(AdHocCommand) # activity_stream_registrar.connect(JobHostSummary) # activity_stream_registrar.connect(JobEvent) #activity_stream_registrar.connect(Profile) diff --git a/awx/main/models/activity_stream.py b/awx/main/models/activity_stream.py index 780b3b4ea2..bbf5a76925 100644 --- a/awx/main/models/activity_stream.py +++ b/awx/main/models/activity_stream.py @@ -50,6 +50,7 @@ class ActivityStream(models.Model): job = models.ManyToManyField("Job", blank=True) unified_job_template = models.ManyToManyField("UnifiedJobTemplate", blank=True, related_name='activity_stream_as_unified_job_template+') unified_job = models.ManyToManyField("UnifiedJob", blank=True, related_name='activity_stream_as_unified_job+') + ad_hoc_command = models.ManyToManyField("AdHocCommand", blank=True) schedule = models.ManyToManyField("Schedule", blank=True) custom_inventory_script = models.ManyToManyField("CustomInventoryScript", blank=True) diff --git a/awx/main/models/ad_hoc_commands.py b/awx/main/models/ad_hoc_commands.py new file mode 100644 index 0000000000..8f81481793 --- /dev/null +++ b/awx/main/models/ad_hoc_commands.py @@ -0,0 +1,311 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Python +import hmac +import json +import logging + +# Django +from django.conf import settings +from django.db import models +from django.db.models import Q +from django.utils.translation import ugettext_lazy as _ +from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse + +# Django-JSONField +from jsonfield import JSONField + +# AWX +from awx.main.constants import CLOUD_PROVIDERS +from awx.main.models.base import * # noqa +from awx.main.models.unified_jobs import * # noqa +from awx.main.utils import decrypt_field, ignore_inventory_computed_fields +from awx.main.utils import emit_websocket_notification + +logger = logging.getLogger('awx.main.models.ad_hoc_commands') + +__all__ = ['AdHocCommand', 'AdHocCommandEvent'] + + +class AdHocCommand(UnifiedJob): + + MODULE_NAME_CHOICES = [(x,x) for x in settings.AD_HOC_COMMANDS] + + PRIVILEGE_ESCALATION_CHOICES = [ + ('', _('None')), + ('su', _('su')), + ('sudo', _('sudo')), + ] + + class Meta(object): + app_label = 'main' + + job_type = models.CharField( + max_length=64, + choices=JOB_TYPE_CHOICES, + default='run', + ) + inventory = models.ForeignKey( + 'Inventory', + related_name='ad_hoc_commands', + null=True, + on_delete=models.SET_NULL, + ) + limit = models.CharField( + max_length=1024, + blank=True, + default='', + ) + credential = models.ForeignKey( + 'Credential', + related_name='ad_hoc_commands', + null=True, + default=None, + on_delete=models.SET_NULL, + ) + module_name = models.CharField( + max_length=1024, + default='command', + choices=MODULE_NAME_CHOICES, + blank=True, # If blank, defaults to 'command'. + ) + module_args = models.TextField( + blank=True, + default='', + ) + forks = models.PositiveIntegerField( + blank=True, + default=0, + ) + verbosity = models.PositiveIntegerField( + blank=True, + default=0, + ) + privilege_escalation = models.CharField( + max_length=64, + choices=PRIVILEGE_ESCALATION_CHOICES, + default='', + blank=True, + ) + hosts = models.ManyToManyField( + 'Host', + related_name='ad_hoc_commands', + editable=False, + through='AdHocCommandEvent', + ) + + def clean_credential(self): + cred = self.credential + if cred and cred.kind != 'ssh': + raise ValidationError( + 'You must provide a machine / SSH credential.', + ) + return cred + + def clean_limit(self): + # FIXME: Future feature - check if no hosts would match and reject the + # command, instead of having to run it to find out. + return self.limit + + def clean_module_name(self): + module_name = self.module_name.strip() or 'command' + if module_name not in settings.AD_HOC_COMMANDS: + raise ValidationError('Unsupported module for ad hoc commands.') + return module_name + + def clean_module_args(self): + module_args = self.module_args + if self.module_name in ('command', 'shell') and not module_args: + raise ValidationError('No argument passed to %s module.' % self.module_name) + return module_args + + @property + def passwords_needed_to_start(self): + '''Return list of password field names needed to start the job.''' + needed = [] + if self.credential: + for pw in self.credential.passwords_needed: + if pw == 'password': + needed.append('ssh_password') + else: + needed.append(pw) + return needed + + @classmethod + def _get_parent_field_name(cls): + return '' + + @classmethod + def _get_task_class(cls): + from awx.main.tasks import RunAdHocCommand + return RunAdHocCommand + + def get_absolute_url(self): + return reverse('api:ad_hoc_command_detail', args=(self.pk,)) + + @property + def task_auth_token(self): + '''Return temporary auth token used for task requests via API.''' + if self.status == 'running': + h = hmac.new(settings.SECRET_KEY, self.created.isoformat()) + return '%d-%s' % (self.pk, h.hexdigest()) + + def get_passwords_needed_to_start(self): + return self.passwords_needed_to_start + + def is_blocked_by(self, obj): + from awx.main.models import InventoryUpdate + if type(obj) == InventoryUpdate: + if self.inventory == obj.inventory_source.inventory: + return True + return False + + @property + def task_impact(self): + # 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(active=True, enabled=True, inventory__ad_hoc_commands__pk=self.pk).count() + return min(count_hosts, 5 if self.forks == 0 else self.forks) * 10 + + def generate_dependencies(self, active_tasks): + from awx.main.models import InventoryUpdate + if not self.inventory: + return [] + inventory_sources = self.inventory.inventory_sources.filter(active=True, update_on_launch=True) + inventory_sources_found = [] + dependencies = [] + for obj in active_tasks: + if type(obj) == InventoryUpdate: + if obj.inventory_source in inventory_sources: + inventory_sources_found.append(obj.inventory_source) + # Skip updating any inventory sources that were already updated before + # running this job (via callback inventory refresh). + try: + start_args = json.loads(decrypt_field(self, 'start_args')) + except Exception: + start_args = None + start_args = start_args or {} + inventory_sources_already_updated = start_args.get('inventory_sources_already_updated', []) + if inventory_sources_already_updated: + for source in inventory_sources.filter(pk__in=inventory_sources_already_updated): + if source not in inventory_sources_found: + inventory_sources_found.append(source) + if inventory_sources.count(): # and not has_setup_failures? Probably handled as an error scenario in the task runner + for source in inventory_sources: + if source not in inventory_sources_found and source.needs_update_on_launch: + dependencies.append(source.create_inventory_update(launch_type='dependency')) + return dependencies + + def copy(self): + raise NotImplementedError + presets = {} + for kw in self.job_template._get_unified_job_field_names(): + presets[kw] = getattr(self, kw) + return self.job_template.create_unified_job(**presets) + + +class AdHocCommandEvent(CreatedModifiedModel): + ''' + An event/message logged from the ad hoc event callback for each host. + ''' + + EVENT_TYPES = [ + # (event, verbose name, failed) + ('runner_on_failed', _('Host Failed'), True), + ('runner_on_ok', _('Host OK'), False), + ('runner_on_unreachable', _('Host Unreachable'), True), + # Tower won't see no_hosts (check is done earlier without callback). + #('runner_on_no_hosts', _('No Hosts Matched'), False), + # Tower should probably never see skipped (no conditionals). + #('runner_on_skipped', _('Host Skipped'), False), + # Tower does not support async for ad hoc commands. + #('runner_on_async_poll', _('Host Polling'), False), + #('runner_on_async_ok', _('Host Async OK'), False), + #('runner_on_async_failed', _('Host Async Failure'), True), + # Tower does not yet support --diff mode + #('runner_on_file_diff', _('File Difference'), False), + ] + FAILED_EVENTS = [x[0] for x in EVENT_TYPES if x[2]] + EVENT_CHOICES = [(x[0], x[1]) for x in EVENT_TYPES] + + class Meta: + app_label = 'main' + unique_together = [('ad_hoc_command', 'host_name')] + ordering = ('-pk',) + + ad_hoc_command = models.ForeignKey( + 'AdHocCommand', + related_name='ad_hoc_command_events', + on_delete=models.CASCADE, + editable=False, + ) + host = models.ForeignKey( + 'Host', + related_name='ad_hoc_command_events', + null=True, + default=None, + on_delete=models.SET_NULL, + editable=False, + ) + host_name = models.CharField( + max_length=1024, + default='', + editable=False, + ) + event = models.CharField( + max_length=100, + choices=EVENT_CHOICES, + ) + event_data = JSONField( + blank=True, + default={}, + ) + failed = models.BooleanField( + default=False, + editable=False, + ) + changed = models.BooleanField( + default=False, + editable=False, + ) + counter = models.PositiveIntegerField( + default=0, + ) + + def get_absolute_url(self): + return reverse('api:ad_hoc_command_event_detail', args=(self.pk,)) + + def __unicode__(self): + return u'%s @ %s' % (self.get_event_display(), self.created.isoformat()) + + def save(self, *args, **kwargs): + from awx.main.models.inventory import Host + # If update_fields has been specified, add our field names to it, + # if it hasn't been specified, then we're just doing a normal save. + update_fields = kwargs.get('update_fields', []) + res = self.event_data.get('res', None) + if self.event in self.FAILED_EVENTS: + if not self.event_data.get('ignore_errors', False): + self.failed = True + if 'failed' not in update_fields: + update_fields.append('failed') + if isinstance(res, dict) and res.get('changed', False): + self.changed = True + if 'changed' not in update_fields: + update_fields.append('changed') + self.host_name = self.event_data.get('host', '').strip() + if 'host_name' not in update_fields: + update_fields.append('host_name') + try: + if not self.host_id and self.host_name: + host_qs = Host.objects.filter(inventory__ad_hoc_commands__id=self.ad_hoc_command_id, name=self.host_name) + host_id = host_qs.only('id').values_list('id', flat=True) + if host_id.exists(): + self.host_id = host_id[0] + if 'host_id' not in update_fields: + update_fields.append('host_id') + except (IndexError, AttributeError): + pass + super(AdHocCommandEvent, self).save(*args, **kwargs) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 9f9eb8bd61..a9140eff49 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -722,6 +722,11 @@ class Group(CommonModelNameNotUnique): from awx.main.models.jobs import JobEvent return JobEvent.objects.filter(host__in=self.all_hosts) + @property + def ad_hoc_commands(self): + from awx.main.models.ad_hoc_commands import AdHocCommand + return AdHocCommand.objects.filter(hosts__in=self.all_hosts) + class InventorySourceOptions(BaseModel): ''' diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 088778a932..dcc3b448fa 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -935,6 +935,7 @@ class JobEvent(CreatedModifiedModel): job.inventory.update_computed_fields() emit_websocket_notification('/socket.io/jobs', 'summary_complete', dict(unified_job_id=job.id)) + class SystemJobOptions(BaseModel): ''' Common fields for SystemJobTemplate and SystemJob. diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index 1f5a8f1a4c..2ee5cd4eb6 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -128,15 +128,17 @@ class Permission(CommonModelNameNotUnique): # the project parameter is not used when dealing with READ, WRITE, or ADMIN permissions. permission_type = models.CharField(max_length=64, choices=PERMISSION_TYPE_CHOICES) + run_ad_hoc_commands = models.BooleanField(default=False) def __unicode__(self): - return unicode("Permission(name=%s,ON(user=%s,team=%s),FOR(project=%s,inventory=%s,type=%s))" % ( + return unicode("Permission(name=%s,ON(user=%s,team=%s),FOR(project=%s,inventory=%s,type=%s%s))" % ( self.name, self.user, self.team, self.project, self.inventory, - self.permission_type + self.permission_type, + '+adhoc' if self.run_ad_hoc_commands else '', )) def get_absolute_url(self): diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 8cd4f7b9a5..0a34e18bca 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -481,7 +481,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique return u'%s-%s-%s' % (self.created, self.id, self.status) def _get_parent_instance(self): - return getattr(self, self._get_parent_field_name()) + return getattr(self, self._get_parent_field_name(), None) def _update_parent_instance_no_save(self, parent_instance, update_fields=[]): def parent_instance_set(key, val): diff --git a/awx/main/signals.py b/awx/main/signals.py index 556ed8bf57..b3e99a647d 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -42,6 +42,17 @@ def emit_job_event_detail(sender, **kwargs): event_serialized["event_name"] = instance.event emit_websocket_notification('/socket.io/job_events', 'job_events-' + str(instance.job.id), event_serialized) +def emit_ad_hoc_command_event_detail(sender, **kwargs): + instance = kwargs['instance'] + created = kwargs['created'] + if created: + event_serialized = AdHocCommandEventSerializer(instance).data + event_serialized['id'] = instance.id + event_serialized["created"] = event_serialized["created"].isoformat() + event_serialized["modified"] = event_serialized["modified"].isoformat() + event_serialized["event_name"] = instance.event + emit_websocket_notification('/socket.io/ad_hoc_command_events', 'ad_hoc_command_events-' + str(instance.ad_hoc_command_id), event_serialized) + def emit_update_inventory_computed_fields(sender, **kwargs): logger.debug("In update inventory computed fields") if getattr(_inventory_updates, 'is_updating', False): @@ -121,6 +132,7 @@ pre_save.connect(store_initial_active_state, sender=Job) post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Job) post_save.connect(emit_job_event_detail, sender=JobEvent) +post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) # Migrate hosts, groups to parent group(s) whenever a group is deleted or # marked as inactive. diff --git a/awx/main/tasks.py b/awx/main/tasks.py index ececc633de..627bacfd08 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -42,7 +42,7 @@ from awx.main.utils import (get_ansible_version, decrypt_field, update_scm_url, check_proot_installed, build_proot_temp_dir, wrap_args_with_proot) __all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate', - 'handle_work_error', 'update_inventory_computed_fields'] + 'RunAdHocCommand', 'handle_work_error', 'update_inventory_computed_fields'] HIDDEN_PASSWORD = '**********' @@ -137,6 +137,9 @@ def handle_work_error(self, task_id, subtasks=None): elif each_task['type'] == 'job': instance = Job.objects.get(id=each_task['id']) instance_name = instance.job_template.name + elif each_task['type'] == 'ad_hoc_command': + instance = AdHocCommand.objects.get(id=each_task['id']) + instance_name = instance.module_name else: # Unknown task type break @@ -1130,6 +1133,164 @@ class RunInventoryUpdate(BaseTask): def get_idle_timeout(self): return getattr(settings, 'INVENTORY_UPDATE_IDLE_TIMEOUT', None) + +class RunAdHocCommand(BaseTask): + ''' + Celery task to run an ad hoc command using ansible. + ''' + + name = 'awx.main.tasks.run_ad_hoc_command' + model = AdHocCommand + + def build_private_data(self, ad_hoc_command, **kwargs): + ''' + Return SSH private key data needed for this ad hoc command (only if + stored in DB as ssh_key_data). + ''' + # If we were sent SSH credentials, decrypt them and send them + # back (they will be written to a temporary file). + creds = ad_hoc_command.credential + if creds: + return decrypt_field(creds, 'ssh_key_data') or None + + def build_passwords(self, ad_hoc_command, **kwargs): + ''' + Build a dictionary of passwords for SSH private key, SSH user and + sudo/su. + ''' + passwords = super(RunAdHocCommand, self).build_passwords(ad_hoc_command, **kwargs) + creds = ad_hoc_command.credential + if creds: + for field in ('ssh_key_unlock', 'ssh_password', 'sudo_password', 'su_password'): + if field == 'ssh_password': + value = kwargs.get(field, decrypt_field(creds, 'password')) + else: + value = kwargs.get(field, decrypt_field(creds, field)) + if value not in ('', 'ASK'): + passwords[field] = value + return passwords + + def build_env(self, ad_hoc_command, **kwargs): + ''' + Build environment dictionary for ansible. + ''' + plugin_dir = self.get_path_to('..', 'plugins', 'callback') + env = super(RunAdHocCommand, self).build_env(ad_hoc_command, **kwargs) + # Set environment variables needed for inventory and ad hoc event + # callbacks to work. + env['AD_HOC_COMMAND_ID'] = str(ad_hoc_command.pk) + env['INVENTORY_ID'] = str(ad_hoc_command.inventory.pk) + env['INVENTORY_HOSTVARS'] = str(True) + env['ANSIBLE_CALLBACK_PLUGINS'] = plugin_dir + env['ANSIBLE_LOAD_CALLBACK_PLUGINS'] = '1' + env['REST_API_URL'] = settings.INTERNAL_API_URL + env['REST_API_TOKEN'] = ad_hoc_command.task_auth_token or '' + env['CALLBACK_CONSUMER_PORT'] = str(settings.CALLBACK_CONSUMER_PORT) + if getattr(settings, 'JOB_CALLBACK_DEBUG', False): + env['JOB_CALLBACK_DEBUG'] = '2' + elif settings.DEBUG: + env['JOB_CALLBACK_DEBUG'] = '1' + + # Create a directory for ControlPath sockets that is unique to each + # ad hoc command and visible inside the proot environment (when enabled). + cp_dir = os.path.join(kwargs['private_data_dir'], 'cp') + if not os.path.exists(cp_dir): + os.mkdir(cp_dir, 0700) + env['ANSIBLE_SSH_CONTROL_PATH'] = os.path.join(cp_dir, 'ansible-ssh-%%h-%%p-%%r') + + return env + + def build_args(self, ad_hoc_command, **kwargs): + ''' + Build command line argument list for running ansible, optionally using + ssh-agent for public/private key authentication. + ''' + creds = ad_hoc_command.credential + ssh_username, sudo_username, su_username = '', '', '' + if creds: + ssh_username = kwargs.get('username', creds.username) + sudo_username = kwargs.get('sudo_username', creds.sudo_username) + su_username = kwargs.get('su_username', creds.su_username) + # Always specify the normal SSH user as root by default. Since this + # task is normally running in the background under a service account, + # it doesn't make sense to rely on ansible's default of using the + # current user. + ssh_username = ssh_username or 'root' + inventory_script = self.get_path_to('..', 'plugins', 'inventory', + 'awxrest.py') + args = ['ansible', '-i', inventory_script] + if ad_hoc_command.job_type == 'check': + args.append('--check') + args.extend(['-u', ssh_username]) + if 'ssh_password' in kwargs.get('passwords', {}): + args.append('--ask-pass') + # We only specify sudo/su user and password if explicitly given by the + # credential. Credential should never specify both sudo and su. + if su_username: + args.extend(['-R', su_username]) + if 'su_password' in kwargs.get('passwords', {}): + args.append('--ask-su-pass') + if sudo_username: + args.extend(['-U', sudo_username]) + if 'sudo_password' in kwargs.get('passwords', {}): + args.append('--ask-sudo-pass') + if ad_hoc_command.privilege_escalation == 'sudo': + args.append('--sudo') + elif ad_hoc_command.privilege_escalation == 'su': + args.append('--su') + + if ad_hoc_command.forks: # FIXME: Max limit? + args.append('--forks=%d' % ad_hoc_command.forks) + if ad_hoc_command.verbosity: + args.append('-%s' % ('v' * min(3, ad_hoc_command.verbosity))) + + args.extend(['-m', ad_hoc_command.module_name]) + args.extend(['-a', ad_hoc_command.module_args]) + + if ad_hoc_command.limit: + args.append(ad_hoc_command.limit) + else: + args.append('all') + + return args + + def build_cwd(self, ad_hoc_command, **kwargs): + return kwargs['private_data_dir'] + + def get_idle_timeout(self): + return getattr(settings, 'JOB_RUN_IDLE_TIMEOUT', None) + + def get_password_prompts(self): + d = super(RunAdHocCommand, self).get_password_prompts() + d[re.compile(r'^Enter passphrase for .*:\s*?$', re.M)] = 'ssh_key_unlock' + d[re.compile(r'^Bad passphrase, try again for .*:\s*?$', re.M)] = '' + d[re.compile(r'^sudo password.*:\s*?$', re.M)] = 'sudo_password' + d[re.compile(r'^SUDO password.*:\s*?$', re.M)] = 'sudo_password' + d[re.compile(r'^su password.*:\s*?$', re.M)] = 'su_password' + d[re.compile(r'^SU password.*:\s*?$', re.M)] = 'su_password' + d[re.compile(r'^SSH password:\s*?$', re.M)] = 'ssh_password' + d[re.compile(r'^Password:\s*?$', re.M)] = 'ssh_password' + return d + + def get_ssh_key_path(self, instance, **kwargs): + ''' + If using an SSH key, return the path for use by ssh-agent. + ''' + return kwargs.get('private_data_file', '') + + def should_use_proot(self, instance, **kwargs): + ''' + Return whether this task should use proot. + ''' + return getattr(settings, 'AWX_PROOT_ENABLED', False) + + def post_run_hook(self, ad_hoc_command, **kwargs): + ''' + Hook for actions to run after ad hoc command has completed. + ''' + super(RunAdHocCommand, self).post_run_hook(ad_hoc_command, **kwargs) + + class RunSystemJob(BaseTask): name = 'awx.main.tasks.run_system_job' diff --git a/awx/main/tests/__init__.py b/awx/main/tests/__init__.py index 7489dbd7b3..6ba54a7802 100644 --- a/awx/main/tests/__init__.py +++ b/awx/main/tests/__init__.py @@ -8,6 +8,7 @@ from awx.main.tests.projects import ProjectsTest, ProjectUpdatesTest # noqa from awx.main.tests.commands import * # noqa from awx.main.tests.scripts import * # noqa from awx.main.tests.tasks import RunJobTest # noqa +from awx.main.tests.ad_hoc import * # noqa from awx.main.tests.licenses import LicenseTests # noqa from awx.main.tests.jobs import * # noqa from awx.main.tests.activity_stream import * # noqa diff --git a/awx/main/tests/ad_hoc.py b/awx/main/tests/ad_hoc.py new file mode 100644 index 0000000000..c0071f4f2e --- /dev/null +++ b/awx/main/tests/ad_hoc.py @@ -0,0 +1,720 @@ +# Copyright (c) 2015 Ansible, Inc. +# All Rights Reserved. + +# Python +from distutils.version import StrictVersion as Version +import glob +import json +import os +import shutil +import subprocess +import tempfile +import unittest + +# Django +from django.conf import settings +from django.core.urlresolvers import reverse +from django.utils.timezone import now + +# Django-CRUM +from crum import impersonate + +# AWX +from awx.main.models import * # noqa +from awx.main.tests.base import BaseJobExecutionTest +from awx.main.tests.tasks import TEST_SSH_KEY_DATA, TEST_SSH_KEY_DATA_LOCKED, TEST_SSH_CERT_KEY, TEST_SSH_KEY_DATA_UNLOCK + +__all__ = ['RunAdHocCommandTest', 'AdHocCommandApiTest'] + + +class BaseAdHocCommandTest(BaseJobExecutionTest): + ''' + Common initialization for testing ad hoc commands. + ''' + + def setUp(self): + super(BaseAdHocCommandTest, self).setUp() + self.setup_instances() + self.setup_users() + self.organization = self.make_organizations(self.super_django_user, 1)[0] + self.organization.admins.add(self.normal_django_user) + self.inventory = self.organization.inventories.create(name='test-inventory', description='description for test-inventory') + self.host = self.inventory.hosts.create(name='host.example.com') + self.host2 = self.inventory.hosts.create(name='host2.example.com') + self.group = self.inventory.groups.create(name='test-group') + self.group2 = self.inventory.groups.create(name='test-group2') + self.group.hosts.add(self.host) + self.group2.hosts.add(self.host, self.host2) + self.credential = None + settings.INTERNAL_API_URL = self.live_server_url + settings.CALLBACK_CONSUMER_PORT = '' + + def create_test_credential(self, **kwargs): + self.credential = self.make_credential(**kwargs) + return self.credential + + +class RunAdHocCommandTest(BaseAdHocCommandTest): + ''' + Test cases for RunAdHocCommand celery task. + ''' + + def create_test_ad_hoc_command(self, **kwargs): + with impersonate(self.super_django_user): + opts = { + 'inventory': self.inventory, + 'credential': self.credential, + 'job_type': 'run', + 'module_name': 'command', + 'module_args': 'uptime', + } + opts.update(kwargs) + self.ad_hoc_command = AdHocCommand.objects.create(**opts) + return self.ad_hoc_command + + def check_ad_hoc_command_events(self, ad_hoc_command, runner_status='ok', + hosts=None): + ad_hoc_command_events = ad_hoc_command.ad_hoc_command_events.all() + for ad_hoc_command_event in ad_hoc_command_events: + unicode(ad_hoc_command_event) # For test coverage. + should_be_failed = bool(runner_status not in ('ok', 'skipped')) + should_be_changed = bool(runner_status in ('ok', 'failed') and ad_hoc_command.job_type == 'run') + if hosts is not None: + host_pks = set([x.pk for x in hosts]) + else: + host_pks = set(ad_hoc_command.inventory.hosts.values_list('pk', flat=True)) + qs = ad_hoc_command_events.filter(event=('runner_on_%s' % runner_status)) + self.assertEqual(qs.count(), len(host_pks)) + for evt in qs: + self.assertTrue(evt.host_id in host_pks) + self.assertTrue(evt.host_name) + self.assertEqual(evt.failed, should_be_failed) + self.assertEqual(evt.changed, should_be_changed) + + def test_run_ad_hoc_command(self): + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.check_ad_hoc_command_events(ad_hoc_command, 'ok') + + def test_check_mode_ad_hoc_command(self): + ad_hoc_command = self.create_test_ad_hoc_command(module_name='ping', module_args='', job_type='check') + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.check_ad_hoc_command_events(ad_hoc_command, 'ok') + + def test_run_ad_hoc_command_that_fails(self): + ad_hoc_command = self.create_test_ad_hoc_command(module_args='false') + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'failed') + self.check_ad_hoc_command_events(ad_hoc_command, 'failed') + + def test_check_mode_where_command_would_fail(self): + ad_hoc_command = self.create_test_ad_hoc_command(job_type='check', module_args='false') + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'failed') + self.check_ad_hoc_command_events(ad_hoc_command, 'unreachable') + + def test_cancel_ad_hoc_command(self): + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.cancel_flag) + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + ad_hoc_command.cancel_flag = True + ad_hoc_command.save(update_fields=['cancel_flag']) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'canceled') + self.assertTrue(ad_hoc_command.cancel_flag) + # Calling cancel afterwards just returns the cancel flag. + self.assertTrue(ad_hoc_command.cancel()) + # Read attribute for test coverage. + ad_hoc_command.celery_task + ad_hoc_command.celery_task_id = '' + ad_hoc_command.save(update_fields=['celery_task_id']) + self.assertEqual(ad_hoc_command.celery_task, None) + # Unable to start ad hoc command again. + self.assertFalse(ad_hoc_command.signal_start()) + + def test_ad_hoc_command_options(self): + ad_hoc_command = self.create_test_ad_hoc_command(forks=2, verbosity=2) + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.assertTrue('"--forks=2"' in ad_hoc_command.job_args) + self.assertTrue('"-vv"' in ad_hoc_command.job_args) + # Test with sudo privilege escalation. + ad_hoc_command2 = self.create_test_ad_hoc_command(privilege_escalation='sudo') + self.assertEqual(ad_hoc_command2.status, 'new') + self.assertFalse(ad_hoc_command2.passwords_needed_to_start) + self.assertTrue(ad_hoc_command2.signal_start()) + ad_hoc_command2 = AdHocCommand.objects.get(pk=ad_hoc_command2.pk) + self.check_job_result(ad_hoc_command2, ('successful', 'failed')) + self.assertTrue('"--sudo"' in ad_hoc_command2.job_args) + # Test with su privilege escalation. + ad_hoc_command3 = self.create_test_ad_hoc_command(privilege_escalation='su') + self.assertEqual(ad_hoc_command3.status, 'new') + self.assertFalse(ad_hoc_command3.passwords_needed_to_start) + self.assertTrue(ad_hoc_command3.signal_start()) + ad_hoc_command3 = AdHocCommand.objects.get(pk=ad_hoc_command3.pk) + self.check_job_result(ad_hoc_command3, ('successful', 'failed')) + self.assertTrue('"--su"' in ad_hoc_command3.job_args) + + def test_limit_option(self): + # Test limit by hostname. + ad_hoc_command = self.create_test_ad_hoc_command(limit='host.example.com') + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.check_ad_hoc_command_events(ad_hoc_command, 'ok', hosts=[self.host]) + self.assertTrue('"host.example.com"' in ad_hoc_command.job_args) + # Test limit by group name. + ad_hoc_command2 = self.create_test_ad_hoc_command(limit='test-group') + self.assertEqual(ad_hoc_command2.status, 'new') + self.assertFalse(ad_hoc_command2.passwords_needed_to_start) + self.assertTrue(ad_hoc_command2.signal_start()) + ad_hoc_command2 = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command2, 'successful') + self.check_ad_hoc_command_events(ad_hoc_command2, 'ok', hosts=[self.host]) + # Test limit by host not in inventory. + ad_hoc_command3 = self.create_test_ad_hoc_command(limit='bad-host') + self.assertEqual(ad_hoc_command3.status, 'new') + self.assertFalse(ad_hoc_command3.passwords_needed_to_start) + self.assertTrue(ad_hoc_command3.signal_start()) + ad_hoc_command3 = AdHocCommand.objects.get(pk=ad_hoc_command3.pk) + self.check_job_result(ad_hoc_command3, 'successful') + self.check_ad_hoc_command_events(ad_hoc_command3, 'ok', hosts=[]) + self.assertEqual(ad_hoc_command3.ad_hoc_command_events.count(), 0) + + def test_ssh_username_and_password(self): + self.create_test_credential(username='sshuser', password='sshpass') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.assertTrue('"-u"' in ad_hoc_command.job_args) + self.assertTrue('"--ask-pass"' in ad_hoc_command.job_args) + + def test_ssh_ask_password(self): + self.create_test_credential(password='ASK') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertTrue(ad_hoc_command.passwords_needed_to_start) + self.assertTrue('ssh_password' in ad_hoc_command.passwords_needed_to_start) + self.assertFalse(ad_hoc_command.signal_start()) + self.assertTrue(ad_hoc_command.signal_start(ssh_password='sshpass')) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.assertTrue('"--ask-pass"' in ad_hoc_command.job_args) + + def test_sudo_username_and_password(self): + self.create_test_credential(sudo_username='sudouser', + sudo_password='sudopass') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + # Job may fail if current user doesn't have password-less sudo + # privileges, but we're mainly checking the command line arguments. + self.check_job_result(ad_hoc_command, ('successful', 'failed')) + self.assertTrue('"-U"' in ad_hoc_command.job_args) + self.assertTrue('"--ask-sudo-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--sudo"' in ad_hoc_command.job_args) + self.assertFalse('"-R"' in ad_hoc_command.job_args) + self.assertFalse('"--ask-su-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--su"' in ad_hoc_command.job_args) + + def test_sudo_ask_password(self): + self.create_test_credential(sudo_password='ASK') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertTrue(ad_hoc_command.passwords_needed_to_start) + self.assertTrue('sudo_password' in ad_hoc_command.passwords_needed_to_start) + self.assertFalse(ad_hoc_command.signal_start()) + self.assertTrue(ad_hoc_command.signal_start(sudo_password='sudopass')) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + # Job may fail, but we're mainly checking the command line arguments. + self.check_job_result(ad_hoc_command, ('successful', 'failed')) + self.assertTrue('"--ask-sudo-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--sudo"' in ad_hoc_command.job_args) + self.assertFalse('"-R"' in ad_hoc_command.job_args) + self.assertFalse('"--ask-su-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--su"' in ad_hoc_command.job_args) + + def test_su_username_and_password(self): + self.create_test_credential(su_username='suuser', su_password='supass') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + # Job may fail, but we're mainly checking the command line arguments. + self.check_job_result(ad_hoc_command, ('successful', 'failed')) + self.assertTrue('"-R"' in ad_hoc_command.job_args) + self.assertTrue('"--ask-su-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--su"' in ad_hoc_command.job_args) + self.assertFalse('"-U"' in ad_hoc_command.job_args) + self.assertFalse('"--ask-sudo-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--sudo"' in ad_hoc_command.job_args) + + def test_su_ask_password(self): + self.create_test_credential(su_password='ASK') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertTrue(ad_hoc_command.passwords_needed_to_start) + self.assertTrue('su_password' in ad_hoc_command.passwords_needed_to_start) + self.assertFalse(ad_hoc_command.signal_start()) + self.assertTrue(ad_hoc_command.signal_start(su_password='sudopass')) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + # Job may fail, but we're mainly checking the command line arguments. + self.check_job_result(ad_hoc_command, ('successful', 'failed')) + self.assertTrue('"--ask-su-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--su"' in ad_hoc_command.job_args) + self.assertFalse('"--ask-sudo-pass"' in ad_hoc_command.job_args) + self.assertFalse('"--sudo"' in ad_hoc_command.job_args) + + def test_unlocked_ssh_key(self): + self.create_test_credential(ssh_key_data=TEST_SSH_KEY_DATA) + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.assertFalse('"--private-key=' in ad_hoc_command.job_args) + self.assertTrue('ssh-agent' in ad_hoc_command.job_args) + + def test_locked_ssh_key_with_password(self): + self.create_test_credential(ssh_key_data=TEST_SSH_KEY_DATA_LOCKED, + ssh_key_unlock=TEST_SSH_KEY_DATA_UNLOCK) + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.assertTrue('ssh-agent' in ad_hoc_command.job_args) + self.assertTrue('Bad passphrase' not in ad_hoc_command.result_stdout) + + def test_locked_ssh_key_with_bad_password(self): + self.create_test_credential(ssh_key_data=TEST_SSH_KEY_DATA_LOCKED, + ssh_key_unlock='not the passphrase') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'failed') + self.assertTrue('ssh-agent' in ad_hoc_command.job_args) + self.assertTrue('Bad passphrase' in ad_hoc_command.result_stdout) + + def test_locked_ssh_key_ask_password(self): + self.create_test_credential(ssh_key_data=TEST_SSH_KEY_DATA_LOCKED, + ssh_key_unlock='ASK') + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertTrue(ad_hoc_command.passwords_needed_to_start) + self.assertTrue('ssh_key_unlock' in ad_hoc_command.passwords_needed_to_start) + self.assertFalse(ad_hoc_command.signal_start()) + self.assertTrue(ad_hoc_command.signal_start(ssh_key_unlock='not it')) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'failed') + self.assertTrue('ssh-agent' in ad_hoc_command.job_args) + self.assertTrue('Bad passphrase' in ad_hoc_command.result_stdout) + # Try again and pass correct password. + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertTrue(ad_hoc_command.passwords_needed_to_start) + self.assertTrue('ssh_key_unlock' in ad_hoc_command.passwords_needed_to_start) + self.assertFalse(ad_hoc_command.signal_start()) + self.assertTrue(ad_hoc_command.signal_start(ssh_key_unlock=TEST_SSH_KEY_DATA_UNLOCK)) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.assertTrue('ssh-agent' in ad_hoc_command.job_args) + self.assertTrue('Bad passphrase' not in ad_hoc_command.result_stdout) + + def test_run_with_proot(self): + # Only run test if proot is installed + cmd = [getattr(settings, 'AWX_PROOT_CMD', 'proot'), '--version'] + try: + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + proc.communicate() + has_proot = bool(proc.returncode == 0) + except (OSError, ValueError): + has_proot = False + if not has_proot: + self.skipTest('proot is not installed') + # Enable proot for this test. + settings.AWX_PROOT_ENABLED = True + # Hide local settings path. + settings.AWX_PROOT_HIDE_PATHS = [os.path.join(settings.BASE_DIR, 'settings')] + # Create list of paths that should not be visible to the command. + hidden_paths = [ + os.path.join(settings.PROJECTS_ROOT, '*'), + os.path.join(settings.JOBOUTPUT_ROOT, '*'), + ] + # Create a temp directory that should not be visible to the command. + temp_path = tempfile.mkdtemp() + self._temp_paths.append(temp_path) + hidden_paths.append(temp_path) + # Find a file in supervisor logs that should not be visible. + try: + supervisor_log_path = glob.glob('/var/log/supervisor/*')[0] + except IndexError: + supervisor_log_path = None + if supervisor_log_path: + hidden_paths.append(supervisor_log_path) + # Create and run ad hoc command. + module_args = ' && '.join(['echo %s && test ! -e %s' % (x, x) for x in hidden_paths]) + ad_hoc_command = self.create_test_ad_hoc_command(module_name='shell', module_args=module_args, verbosity=2) + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'successful') + self.check_ad_hoc_command_events(ad_hoc_command, 'ok') + + def test_run_with_proot_not_installed(self): + # Enable proot for this test, specify invalid proot cmd. + settings.AWX_PROOT_ENABLED = True + settings.AWX_PROOT_CMD = 'PR00T' + ad_hoc_command = self.create_test_ad_hoc_command() + self.assertEqual(ad_hoc_command.status, 'new') + self.assertFalse(ad_hoc_command.passwords_needed_to_start) + self.assertTrue(ad_hoc_command.signal_start()) + ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk) + self.check_job_result(ad_hoc_command, 'error', expect_traceback=True) + + +class AdHocCommandApiTest(BaseAdHocCommandTest): + ''' + Test API list/detail views for ad hoc commands. + ''' + + def setUp(self): + super(AdHocCommandApiTest, self).setUp() + self.create_test_credential(user=self.normal_django_user) + + def test_ad_hoc_command_list(self): + url = reverse('api:ad_hoc_command_list') + + # Retrieve the empty list of ad hoc commands. + qs = AdHocCommand.objects.none() + self.check_get_list(url, 'admin', qs) + self.check_get_list(url, 'normal', qs) + self.check_get_list(url, 'other', qs) + self.check_get_list(url, 'nobody', qs) + self.check_get_list(url, None, qs, expect=401) + + # Post to list to start a new ad hoc command. Only admin and normal + # user (org admin) can run commands by default. + data = { + 'inventory': self.inventory.pk, + 'credential': self.credential.pk, + 'module_name': 'command', + 'module_args': 'uptime', + } + with self.current_user('admin'): + response = self.post(url, data, expect=201) + self.assertEqual(response['job_type'], 'run') + self.assertEqual(response['inventory'], self.inventory.pk) + self.assertEqual(response['credential'], self.credential.pk) + self.assertEqual(response['module_name'], 'command') + self.assertEqual(response['module_args'], 'uptime') + self.assertEqual(response['limit'], '') + self.assertEqual(response['forks'], 0) + self.assertEqual(response['verbosity'], 0) + self.assertEqual(response['privilege_escalation'], '') + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user('normal'): + response = self.post(url, data, expect=201) + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user('other'): + response = self.post(url, data, expect=403) + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user('nobody'): + response = self.post(url, data, expect=403) + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user(None): + response = self.post(url, data, expect=401) + self.put(url, data, expect=401) + self.patch(url, data, expect=401) + self.delete(url, expect=401) + + # Retrieve the list of ad hoc commands (only admin/normal can see by default). + qs = AdHocCommand.objects.all() + self.assertEqual(qs.count(), 2) + self.check_get_list(url, 'admin', qs) + self.check_get_list(url, 'normal', qs) + qs = AdHocCommand.objects.none() + self.check_get_list(url, 'other', qs) + self.check_get_list(url, 'nobody', qs) + self.check_get_list(url, None, qs, expect=401) + + # Explicitly give other user admin permission on the inventory (still + # not allowed to run ad hoc commands). + user_perm_url = reverse('api:user_permissions_list', args=(self.other_django_user.pk,)) + user_perm_data = { + 'name': 'Allow Other to Admin Inventory', + 'inventory': self.inventory.pk, + 'permission_type': 'admin', + } + with self.current_user('admin'): + response = self.post(user_perm_url, user_perm_data, expect=201) + user_perm_id = response['id'] + with self.current_user('other'): + response = self.post(url, data, expect=403) + self.check_get_list(url, 'other', qs) + + # Update permission to allow other user to run ad hoc commands. Fails + # when other user can't read credential. + user_perm_url = reverse('api:permission_detail', args=(user_perm_id,)) + user_perm_data.update({ + 'name': 'Allow Other to Admin Inventory and Run Ad Hoc Commands', + 'run_ad_hoc_commands': True, + }) + with self.current_user('admin'): + response = self.patch(user_perm_url, user_perm_data, expect=200) + with self.current_user('other'): + response = self.post(url, data, expect=403) + + # Succeeds once other user has a readable credential. Other user can + # only see his own ad hoc command (because of credential permissions). + other_cred = self.create_test_credential(user=self.other_django_user) + credential_id = data.pop('credential') + data['credential'] = other_cred.pk + with self.current_user('other'): + response = self.post(url, data, expect=201) + qs = AdHocCommand.objects.filter(created_by=self.other_django_user) + self.assertEqual(qs.count(), 1) + self.check_get_list(url, 'other', qs) + + # Explicitly give nobody user read permission on the inventory. + user_perm_url = reverse('api:user_permissions_list', args=(self.nobody_django_user.pk,)) + user_perm_data = { + 'name': 'Allow Nobody to Read Inventory', + 'inventory': self.inventory.pk, + 'permission_type': 'read', + } + with self.current_user('admin'): + response = self.post(user_perm_url, user_perm_data, expect=201) + user_perm_id = response['id'] + with self.current_user('nobody'): + response = self.post(url, data, expect=403) + self.check_get_list(url, 'other', qs) + + # Create a cred for the nobody user, run an ad hoc command as the admin + # user with that cred. Nobody user can still not see the ad hoc command + # without the run_ad_hoc_commands permission flag. + nobody_cred = self.create_test_credential(user=self.nobody_django_user) + credential_id = data.pop('credential') + data['credential'] = nobody_cred.pk + with self.current_user('admin'): + response = self.post(url, data, expect=201) + qs = AdHocCommand.objects.none() + self.check_get_list(url, 'nobody', qs) + + # Give the nobody user the run_ad_hoc_commands flag, and can now see + # the one ad hoc command previously run. + user_perm_url = reverse('api:permission_detail', args=(user_perm_id,)) + user_perm_data.update({ + 'name': 'Allow Nobody to Read Inventory and Run Ad Hoc Commands', + 'run_ad_hoc_commands': True, + }) + with self.current_user('admin'): + response = self.patch(user_perm_url, user_perm_data, expect=200) + qs = AdHocCommand.objects.filter(credential_id=data['credential']) + self.assertEqual(qs.count(), 1) + self.check_get_list(url, 'nobody', qs) + data['credential'] = credential_id + + # Post without inventory (should fail). + inventory_id = data.pop('inventory') + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['inventory'] = inventory_id + + # Post without credential (should fail). + credential_id = data.pop('credential') + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['credential'] = credential_id + + # Post with empty or unsupported module name (empty defaults to command). + module_name = data.pop('module_name') + with self.current_user('admin'): + response = self.post(url, data, expect=201) + self.assertEqual(response['module_name'], 'command') + data['module_name'] = '' + with self.current_user('admin'): + response = self.post(url, data, expect=201) + self.assertEqual(response['module_name'], 'command') + data['module_name'] = 'transcombobulator' + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['module_name'] = module_name + + # Post with empty module args for shell/command modules (should fail), + # empty args for other modules ok. + module_args = data.pop('module_args') + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['module_name'] = 'shell' + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['module_args'] = '' + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['module_name'] = 'ping' + with self.current_user('admin'): + response = self.post(url, data, expect=201) + data['module_name'] = module_name + data['module_args'] = module_args + + # Post with invalid values for other parameters. + data['job_type'] = 'something' + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['job_type'] = 'check' + with self.current_user('admin'): + response = self.post(url, data, expect=201) + self.assertEqual(response['job_type'], 'check') + data.pop('job_type') + data['verbosity'] = -1 + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data.pop('verbosity') + data['forks'] = -1 + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data.pop('forks') + data['privilege_escalation'] = 'telekinesis' + with self.current_user('admin'): + response = self.post(url, data, expect=400) + data['privilege_escalation'] = 'su' + with self.current_user('admin'): + response = self.post(url, data, expect=201) + self.assertEqual(response['privilege_escalation'], 'su') + data['privilege_escalation'] = 'sudo' + with self.current_user('admin'): + response = self.post(url, data, expect=201) + self.assertEqual(response['privilege_escalation'], 'sudo') + + def test_ad_hoc_command_detail(self): + # Post to list to start a new ad hoc command. + url = reverse('api:ad_hoc_command_list') + data = { + 'inventory': self.inventory.pk, + 'credential': self.credential.pk, + 'module_name': 'command', + 'module_args': 'uptime', + } + with self.current_user('admin'): + response = self.post(url, data, expect=201) + + # Retrieve detail for ad hoc command. Only GET is supported. + url = reverse('api:ad_hoc_command_detail', args=(response['id'],)) + self.assertEqual(url, response['url']) + with self.current_user('admin'): + response = self.get(url, expect=200) + self.assertEqual(response['related']['credential'], + reverse('api:credential_detail', args=(self.credential.pk,))) + self.assertEqual(response['related']['inventory'], + reverse('api:inventory_detail', args=(self.inventory.pk,))) + self.assertTrue(response['related']['stdout']) + self.assertTrue(response['related']['cancel']) + self.assertTrue(response['related']['relaunch']) + self.assertTrue(response['related']['events']) + self.assertTrue(response['related']['activity_stream']) + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user('normal'): + response = self.get(url, expect=200) + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user('other'): + response = self.get(url, expect=403) + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user('nobody'): + response = self.get(url, expect=403) + self.put(url, data, expect=405) + self.patch(url, data, expect=405) + self.delete(url, expect=405) + with self.current_user(None): + response = self.get(url, expect=401) + self.put(url, data, expect=401) + self.patch(url, data, expect=401) + self.delete(url, expect=401) + + def test_ad_hoc_command_cancel(self): + # Post to list to start a new ad hoc command. + url = reverse('api:ad_hoc_command_list') + data = { + 'inventory': self.inventory.pk, + 'credential': self.credential.pk, + 'module_name': 'command', + 'module_args': 'uptime', + } + with self.current_user('admin'): + response = self.post(url, data, expect=201) + + url = reverse('api:ad_hoc_command_cancel', args=(response['id'],)) + self.assertEqual(url, response['related']['cancel']) + + # FIXME: Finish test. + + def test_ad_hoc_command_relaunch(self): + self.skipTest('Not yet implemented') + + def test_ad_hoc_command_events_list(self): + self.skipTest('Not yet implemented') + + def test_ad_hoc_command_event_detail(self): + self.skipTest('Not yet implemented') + + def test_ad_hoc_command_activity_stream(self): + self.skipTest('Not yet implemented') + + def test_inventory_ad_hoc_commands_list(self): + self.skipTest('Not yet implemented') + + def test_host_ad_hoc_commands_list(self): + self.skipTest('Not yet implemented') + + def test_group_ad_hoc_commands_list(self): + self.skipTest('Not yet implemented') + + def test_host_ad_hoc_command_events_list(self): + self.skipTest('Not yet implemented') diff --git a/awx/plugins/callback/job_event_callback.py b/awx/plugins/callback/job_event_callback.py index 5a3c25ffe9..ac3780f8ff 100644 --- a/awx/plugins/callback/job_event_callback.py +++ b/awx/plugins/callback/job_event_callback.py @@ -1,4 +1,4 @@ -# Copyright (c) 2014 AnsibleWorks, Inc. +# Copyright (c) 2015 Ansible, Inc. # This file is a utility Ansible plugin that is not part of the AWX or Ansible # packages. It does not import any code from either package, nor does its # license apply to Ansible or AWX. @@ -65,28 +65,12 @@ class TokenAuth(requests.auth.AuthBase): return request -class CallbackModule(object): +class BaseCallbackModule(object): ''' Callback module for logging ansible-playbook job events via the REST API. ''' - # These events should never have an associated play. - EVENTS_WITHOUT_PLAY = [ - 'playbook_on_start', - 'playbook_on_stats', - ] - # These events should never have an associated task. - EVENTS_WITHOUT_TASK = EVENTS_WITHOUT_PLAY + [ - 'playbook_on_setup', - 'playbook_on_notify', - 'playbook_on_import_for_host', - 'playbook_on_not_import_for_host', - 'playbook_on_no_hosts_matched', - 'playbook_on_no_hosts_remaining', - ] - def __init__(self): - self.job_id = int(os.getenv('JOB_ID')) self.base_url = os.getenv('REST_API_URL', '') self.auth_token = os.getenv('REST_API_TOKEN', '') self.callback_consumer_port = os.getenv('CALLBACK_CONSUMER_PORT', '') @@ -128,12 +112,15 @@ class CallbackModule(object): def _post_job_event_queue_msg(self, event, event_data): self.counter += 1 msg = { - 'job_id': self.job_id, 'event': event, 'event_data': event_data, 'counter': self.counter, 'created': datetime.datetime.utcnow().isoformat(), } + if getattr(self, 'job_id', None): + msg['job_id'] = self.job_id + if getattr(self, 'ad_hoc_command_id', None): + msg['ad_hoc_command_id'] = self.ad_hoc_command_id active_pid = os.getpid() if self.job_callback_debug: @@ -148,6 +135,7 @@ class CallbackModule(object): self._init_connection() if self.context is None: self._start_connection() + self.socket.send_json(msg) self.socket.recv() return @@ -174,25 +162,12 @@ class CallbackModule(object): url = urlparse.urlunsplit([parts.scheme, '%s:%d' % (parts.hostname, port), parts.path, parts.query, parts.fragment]) - url_path = '/api/v1/jobs/%d/job_events/' % self.job_id - url = urlparse.urljoin(url, url_path) + url = urlparse.urljoin(url, self.rest_api_path) headers = {'content-type': 'application/json'} response = requests.post(url, data=data, headers=headers, auth=auth) response.raise_for_status() def _log_event(self, event, **event_data): - play = getattr(self, 'play', None) - play_name = getattr(play, 'name', '') - if play_name and event not in self.EVENTS_WITHOUT_PLAY: - event_data['play'] = play_name - task = getattr(self, 'task', None) - task_name = getattr(task, 'name', '') - role_name = getattr(task, 'role_name', '') - if task_name and event not in self.EVENTS_WITHOUT_TASK: - event_data['task'] = task_name - if role_name and event not in self.EVENTS_WITHOUT_TASK: - event_data['role'] = role_name - if self.callback_consumer_port: self._post_job_event_queue_msg(event, event_data) else: @@ -233,58 +208,8 @@ class CallbackModule(object): def runner_on_file_diff(self, host, diff): self._log_event('runner_on_file_diff', host=host, diff=diff) - def playbook_on_start(self): - self._log_event('playbook_on_start') - - def playbook_on_notify(self, host, handler): - self._log_event('playbook_on_notify', host=host, handler=handler) - - def playbook_on_no_hosts_matched(self): - self._log_event('playbook_on_no_hosts_matched') - - def playbook_on_no_hosts_remaining(self): - self._log_event('playbook_on_no_hosts_remaining') - - def playbook_on_task_start(self, name, is_conditional): - self._log_event('playbook_on_task_start', name=name, - is_conditional=is_conditional) - - def playbook_on_vars_prompt(self, varname, private=True, prompt=None, - encrypt=None, confirm=False, salt_size=None, - salt=None, default=None): - self._log_event('playbook_on_vars_prompt', varname=varname, - private=private, prompt=prompt, encrypt=encrypt, - confirm=confirm, salt_size=salt_size, salt=salt, - default=default) - - def playbook_on_setup(self): - self._log_event('playbook_on_setup') - - def playbook_on_import_for_host(self, host, imported_file): - # don't care about recording this one - # self._log_event('playbook_on_import_for_host', host=host, - # imported_file=imported_file) - pass - - def playbook_on_not_import_for_host(self, host, missing_file): - # don't care about recording this one - #self._log_event('playbook_on_not_import_for_host', host=host, - # missing_file=missing_file) - pass - - def playbook_on_play_start(self, name): - # Only play name is passed via callback, get host pattern from the play. - pattern = getattr(getattr(self, 'play', None), 'hosts', name) - self._log_event('playbook_on_play_start', name=name, pattern=pattern) - - def playbook_on_stats(self, stats): - d = {} - for attr in ('changed', 'dark', 'failures', 'ok', 'processed', 'skipped'): - d[attr] = getattr(stats, attr) - self._log_event('playbook_on_stats', **d) - self._terminate_ssh_control_masters() - - def _terminate_ssh_control_masters(self): + @staticmethod + def terminate_ssh_control_masters(): # Determine if control persist is being used and if any open sockets # exist after running the playbook. cp_path = os.environ.get('ANSIBLE_SSH_CONTROL_PATH', '') @@ -341,3 +266,116 @@ class CallbackModule(object): time.sleep(1) for proc in procs_alive: proc.kill() + + +class JobCallbackModule(BaseCallbackModule): + ''' + Callback module for logging ansible-playbook job events via the REST API. + ''' + + # These events should never have an associated play. + EVENTS_WITHOUT_PLAY = [ + 'playbook_on_start', + 'playbook_on_stats', + ] + # These events should never have an associated task. + EVENTS_WITHOUT_TASK = EVENTS_WITHOUT_PLAY + [ + 'playbook_on_setup', + 'playbook_on_notify', + 'playbook_on_import_for_host', + 'playbook_on_not_import_for_host', + 'playbook_on_no_hosts_matched', + 'playbook_on_no_hosts_remaining', + ] + + def __init__(self): + self.job_id = int(os.getenv('JOB_ID', '0')) + self.rest_api_path = '/api/v1/jobs/%d/job_events/' % self.job_id + super(JobCallbackModule, self).__init__() + + def _log_event(self, event, **event_data): + play = getattr(self, 'play', None) + play_name = getattr(play, 'name', '') + if play_name and event not in self.EVENTS_WITHOUT_PLAY: + event_data['play'] = play_name + task = getattr(self, 'task', None) + task_name = getattr(task, 'name', '') + role_name = getattr(task, 'role_name', '') + if task_name and event not in self.EVENTS_WITHOUT_TASK: + event_data['task'] = task_name + if role_name and event not in self.EVENTS_WITHOUT_TASK: + event_data['role'] = role_name + super(JobCallbackModule, self)._log_event(event, **event_data) + + def playbook_on_start(self): + self._log_event('playbook_on_start') + + def playbook_on_notify(self, host, handler): + self._log_event('playbook_on_notify', host=host, handler=handler) + + def playbook_on_no_hosts_matched(self): + self._log_event('playbook_on_no_hosts_matched') + + def playbook_on_no_hosts_remaining(self): + self._log_event('playbook_on_no_hosts_remaining') + + def playbook_on_task_start(self, name, is_conditional): + self._log_event('playbook_on_task_start', name=name, + is_conditional=is_conditional) + + def playbook_on_vars_prompt(self, varname, private=True, prompt=None, + encrypt=None, confirm=False, salt_size=None, + salt=None, default=None): + self._log_event('playbook_on_vars_prompt', varname=varname, + private=private, prompt=prompt, encrypt=encrypt, + confirm=confirm, salt_size=salt_size, salt=salt, + default=default) + + def playbook_on_setup(self): + self._log_event('playbook_on_setup') + + def playbook_on_import_for_host(self, host, imported_file): + # don't care about recording this one + # self._log_event('playbook_on_import_for_host', host=host, + # imported_file=imported_file) + pass + + def playbook_on_not_import_for_host(self, host, missing_file): + # don't care about recording this one + #self._log_event('playbook_on_not_import_for_host', host=host, + # missing_file=missing_file) + pass + + def playbook_on_play_start(self, name): + # Only play name is passed via callback, get host pattern from the play. + pattern = getattr(getattr(self, 'play', None), 'hosts', name) + self._log_event('playbook_on_play_start', name=name, pattern=pattern) + + def playbook_on_stats(self, stats): + d = {} + for attr in ('changed', 'dark', 'failures', 'ok', 'processed', 'skipped'): + d[attr] = getattr(stats, attr) + self._log_event('playbook_on_stats', **d) + self.terminate_ssh_control_masters() + + +class AdHocCommandCallbackModule(BaseCallbackModule): + ''' + Callback module for logging ansible ad hoc events via ZMQ or the REST API. + ''' + + # FIXME: Clean up lingering control persist sockets. + + def __init__(self): + self.ad_hoc_command_id = int(os.getenv('AD_HOC_COMMAND_ID', '0')) + self.rest_api_path = '/api/v1/ad_hoc_commands/%d/events/' % self.ad_hoc_command_id + super(AdHocCommandCallbackModule, self).__init__() + + def runner_on_file_diff(self, host, diff): + pass # Ignore file diff for ad hoc commands. + + +if os.getenv('JOB_ID', ''): + CallbackModule = JobCallbackModule +elif os.getenv('AD_HOC_COMMAND_ID', ''): + CallbackModule = AdHocCommandCallbackModule diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index ba988295fd..adf6cfd7a7 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -343,6 +343,29 @@ AWX_PROOT_SHOW_PATHS = [] # Number of jobs to show as part of the job template history AWX_JOB_TEMPLATE_HISTORY = 10 +# Default list of modules allowed for ad hoc commands. +AD_HOC_COMMANDS = [ + 'command', + 'shell', + 'yum', + 'apt', + 'apt_key', + 'apt_repository', + 'apt_rpm', + 'service', + 'group', + 'user', + 'mount', + 'ping', + 'selinux', + 'setup', + 'win_ping', + 'win_service', + 'win_updates', + 'win_group', + 'win_user', +] + # Not possible to get list of regions without authenticating, so use this list # instead (based on docs from: # http://docs.rackspace.com/loadbalancers/api/v1.0/clb-devguide/content/Service_Access_Endpoints-d1e517.html) diff --git a/awx/templates/rest_framework/api.html b/awx/templates/rest_framework/api.html index d811e2b45a..baf7013be5 100644 --- a/awx/templates/rest_framework/api.html +++ b/awx/templates/rest_framework/api.html @@ -197,7 +197,7 @@ html body .dropdown-submenu:hover>a { {% block footer %} {% endblock %}