# Copyright (c) 2015 Ansible, Inc. # All Rights Reserved. # Python import os import cgi import datetime import dateutil import time import socket import sys import errno from base64 import b64encode from collections import OrderedDict # Django from django.conf import settings from django.contrib.auth.models import User from django.core.urlresolvers import reverse from django.core.exceptions import FieldError from django.db.models import Q, Count from django.db import IntegrityError, transaction from django.shortcuts import get_object_or_404 from django.utils.encoding import force_text from django.utils.safestring import mark_safe from django.utils.timezone import now from django.views.decorators.csrf import csrf_exempt from django.template.loader import render_to_string from django.core.servers.basehttp import FileWrapper from django.http import HttpResponse # Django REST Framework from rest_framework.exceptions import PermissionDenied, ParseError from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.settings import api_settings from rest_framework.views import exception_handler from rest_framework import status # Django REST Framework YAML from rest_framework_yaml.parsers import YAMLParser from rest_framework_yaml.renderers import YAMLRenderer # MongoEngine import mongoengine # QSStats import qsstats # ANSIConv import ansiconv # Python Social Auth from social.backends.utils import load_backends # AWX from awx.main.task_engine import TaskSerializer, TASK_FILE, TEMPORARY_TASK_FILE from awx.main.tasks import mongodb_control from awx.main.access import get_user_queryset from awx.main.ha import is_ha_environment from awx.api.authentication import TaskAuthentication, TokenGetAuthentication from awx.api.utils.decorators import paginated from awx.api.filters import MongoFilterBackend from awx.api.generics import get_view_name from awx.api.generics import * # noqa from awx.api.license import feature_enabled, feature_exists, LicenseForbids from awx.main.models import * # noqa from awx.main.utils import * # noqa from awx.api.permissions import * # noqa from awx.api.renderers import * # noqa from awx.api.serializers import * # noqa from awx.fact.models import * # noqa from awx.main.utils import emit_websocket_notification from awx.main.conf import tower_settings def api_exception_handler(exc, context): ''' Override default API exception handler to catch IntegrityError exceptions. ''' if isinstance(exc, IntegrityError): exc = ParseError(exc.args[0]) if isinstance(exc, FieldError): exc = ParseError(exc.args[0]) return exception_handler(exc, context) class ApiRootView(APIView): authentication_classes = [] permission_classes = (AllowAny,) view_name = 'REST API' def get(self, request, format=None): ''' list supported API versions ''' current = reverse('api:api_v1_root_view', args=[]) data = dict( description = 'Ansible Tower REST API', current_version = current, available_versions = dict( v1 = current ) ) return Response(data) class ApiV1RootView(APIView): authentication_classes = [] permission_classes = (AllowAny,) view_name = 'Version 1' def get(self, request, format=None): ''' list top level resources ''' data = OrderedDict() data['authtoken'] = reverse('api:auth_token_view') data['ping'] = reverse('api:api_v1_ping_view') data['config'] = reverse('api:api_v1_config_view') data['settings'] = reverse('api:settings_list') data['me'] = reverse('api:user_me_list') data['dashboard'] = reverse('api:dashboard_view') data['organizations'] = reverse('api:organization_list') data['users'] = reverse('api:user_list') data['projects'] = reverse('api:project_list') data['teams'] = reverse('api:team_list') data['credentials'] = reverse('api:credential_list') data['inventory'] = reverse('api:inventory_list') data['inventory_scripts'] = reverse('api:inventory_script_list') data['inventory_sources'] = reverse('api:inventory_source_list') data['groups'] = reverse('api:group_list') 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') data['unified_job_templates'] = reverse('api:unified_job_template_list') data['unified_jobs'] = reverse('api:unified_job_list') data['activity_stream'] = reverse('api:activity_stream_list') return Response(data) class ApiV1PingView(APIView): """A simple view that reports very basic information about this Tower instance, which is acceptable to be public information. """ permission_classes = (AllowAny,) authentication_classes = () view_name = 'Ping' new_in_210 = True def get(self, request, format=None): """Return some basic information about this Tower instance. Everything returned here should be considered public / insecure, as this requires no auth and is intended for use by the installer process. """ # Most of this response is canned; just build the dictionary. response = { 'ha': is_ha_environment(), 'role': Instance.objects.my_role(), 'version': get_awx_version(), } # If this is an HA environment, we also include the IP address of # all of the instances. # # Set up a default structure. response['instances'] = { 'primary': None, 'secondaries': [], } # Add all of the instances into the structure. for instance in Instance.objects.all(): if instance.primary: response['instances']['primary'] = instance.hostname else: response['instances']['secondaries'].append(instance.hostname) response['instances']['secondaries'].sort() # Done; return the response. return Response(response) class ApiV1ConfigView(APIView): permission_classes = (IsAuthenticated,) view_name = 'Configuration' def get(self, request, format=None): '''Return various sitewide configuration settings.''' license_reader = TaskSerializer() license_data = license_reader.from_database(show_key=request.user.is_superuser) pendo_state = tower_settings.PENDO_TRACKING_STATE if tower_settings.PENDO_TRACKING_STATE in ('off', 'anonymous', 'detailed') else 'off' data = dict( time_zone=settings.TIME_ZONE, license_info=license_data, version=get_awx_version(), ansible_version=get_ansible_version(), eula=render_to_string("eula.md"), analytics_status=pendo_state ) # If LDAP is enabled, user_ldap_fields will return a list of field # names that are managed by LDAP and should be read-only for users with # a non-empty ldap_dn attribute. if getattr(settings, 'AUTH_LDAP_SERVER_URI', None) and feature_enabled('ldap'): user_ldap_fields = ['username', 'password'] user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_ATTR_MAP', {}).keys()) user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys()) data['user_ldap_fields'] = user_ldap_fields if request.user.is_superuser or request.user.admin_of_organizations.filter(active=True).count(): data.update(dict( project_base_dir = settings.PROJECTS_ROOT, project_local_paths = Project.get_local_path_choices(), )) return Response(data) def post(self, request): if not request.user.is_superuser: return Response(None, status=status.HTTP_404_NOT_FOUND) if not type(request.data) == dict: return Response({"error": "Invalid license data"}, status=status.HTTP_400_BAD_REQUEST) if "eula_accepted" not in request.data: return Response({"error": "Missing 'eula_accepted' property"}, status=status.HTTP_400_BAD_REQUEST) try: eula_accepted = to_python_boolean(request.data["eula_accepted"]) except ValueError: return Response({"error": "'eula_accepted' value is invalid"}, status=status.HTTP_400_BAD_REQUEST) if not eula_accepted: return Response({"error": "'eula_accepted' must be True"}, status=status.HTTP_400_BAD_REQUEST) request.data.pop("eula_accepted") try: data_actual = json.dumps(request.data) except Exception: # FIX: Log return Response({"error": "Invalid JSON"}, status=status.HTTP_400_BAD_REQUEST) license_reader = TaskSerializer() try: license_data = license_reader.from_string(data_actual) except Exception: # FIX: Log return Response({"error": "Invalid License"}, status=status.HTTP_400_BAD_REQUEST) # Sanity check: If this license includes system tracking, make # sure that we have a valid MongoDB to point to, and complain if # we do not. if ('features' in license_data and 'system_tracking' in license_data['features'] and license_data['features']['system_tracking'] and settings.MONGO_HOST == NotImplemented): return Response({ 'error': 'This license supports system tracking, which ' 'requires MongoDB to be installed. Since you are ' 'running in an HA environment, you will need to ' 'provide a MongoDB instance. Please re-run the ' 'installer prior to installing this license.' }, status=status.HTTP_400_BAD_REQUEST) # If the license is valid, write it to disk. if license_data['valid_key']: tower_settings.LICENSE = data_actual # Spawn a task to ensure that MongoDB is started (or stopped) # as appropriate, based on whether the license uses it. if license_data['features']['system_tracking']: mongodb_control.delay('start') else: mongodb_control.delay('stop') # Done; return the response. return Response(license_data) return Response({"error": "Invalid license"}, status=status.HTTP_400_BAD_REQUEST) def delete(self, request): if not request.user.is_superuser: return Response(None, status=status.HTTP_404_NOT_FOUND) # Remove license file has_error = None for fname in (TEMPORARY_TASK_FILE, TASK_FILE): try: os.remove(fname) except OSError, e: if e.errno != errno.ENOENT: has_error = e.errno break TowerSettings.objects.filter(key="LICENSE").delete() # Only stop mongod if license removal succeeded if has_error is None: mongodb_control.delay('stop') return Response(status=status.HTTP_204_NO_CONTENT) else: return Response({"error": "Failed to remove license (%s)" % has_error}, status=status.HTTP_400_BAD_REQUEST) class DashboardView(APIView): view_name = "Dashboard" new_in_14 = True def get(self, request, format=None): ''' Show Dashboard Details ''' data = OrderedDict() data['related'] = {'jobs_graph': reverse('api:dashboard_jobs_graph_view'), 'inventory_graph': reverse('api:dashboard_inventory_graph_view')} user_inventory = get_user_queryset(request.user, Inventory) inventory_with_failed_hosts = user_inventory.filter(hosts_with_active_failures__gt=0) user_inventory_external = user_inventory.filter(has_inventory_sources=True) failed_inventory = sum(i.inventory_sources_with_failures for i in user_inventory) data['inventories'] = {'url': reverse('api:inventory_list'), 'total': user_inventory.count(), 'total_with_inventory_source': user_inventory_external.count(), 'job_failed': inventory_with_failed_hosts.count(), 'inventory_failed': failed_inventory} user_inventory_sources = get_user_queryset(request.user, InventorySource) rax_inventory_sources = user_inventory_sources.filter(source='rax') rax_inventory_failed = rax_inventory_sources.filter(status='failed') ec2_inventory_sources = user_inventory_sources.filter(source='ec2') ec2_inventory_failed = ec2_inventory_sources.filter(status='failed') data['inventory_sources'] = {} data['inventory_sources']['rax'] = {'url': reverse('api:inventory_source_list') + "?source=rax", 'label': 'Rackspace', 'failures_url': reverse('api:inventory_source_list') + "?source=rax&status=failed", 'total': rax_inventory_sources.count(), 'failed': rax_inventory_failed.count()} data['inventory_sources']['ec2'] = {'url': reverse('api:inventory_source_list') + "?source=ec2", 'failures_url': reverse('api:inventory_source_list') + "?source=ec2&status=failed", 'label': 'Amazon EC2', 'total': ec2_inventory_sources.count(), 'failed': ec2_inventory_failed.count()} user_groups = get_user_queryset(request.user, Group) groups_job_failed = (Group.objects.filter(hosts_with_active_failures__gt=0) | Group.objects.filter(groups_with_active_failures__gt=0)).count() groups_inventory_failed = Group.objects.filter(inventory_sources__last_job_failed=True).count() data['groups'] = {'url': reverse('api:group_list'), 'failures_url': reverse('api:group_list') + "?has_active_failures=True", 'total': user_groups.count(), 'job_failed': groups_job_failed, 'inventory_failed': groups_inventory_failed} user_hosts = get_user_queryset(request.user, Host) user_hosts_failed = user_hosts.filter(has_active_failures=True) data['hosts'] = {'url': reverse('api:host_list'), 'failures_url': reverse('api:host_list') + "?has_active_failures=True", 'total': user_hosts.count(), 'failed': user_hosts_failed.count()} user_projects = get_user_queryset(request.user, Project) user_projects_failed = user_projects.filter(last_job_failed=True) data['projects'] = {'url': reverse('api:project_list'), 'failures_url': reverse('api:project_list') + "?last_job_failed=True", 'total': user_projects.count(), 'failed': user_projects_failed.count()} git_projects = user_projects.filter(scm_type='git') git_failed_projects = git_projects.filter(last_job_failed=True) svn_projects = user_projects.filter(scm_type='svn') svn_failed_projects = svn_projects.filter(last_job_failed=True) hg_projects = user_projects.filter(scm_type='hg') hg_failed_projects = hg_projects.filter(last_job_failed=True) data['scm_types'] = {} data['scm_types']['git'] = {'url': reverse('api:project_list') + "?scm_type=git", 'label': 'Git', 'failures_url': reverse('api:project_list') + "?scm_type=git&last_job_failed=True", 'total': git_projects.count(), 'failed': git_failed_projects.count()} data['scm_types']['svn'] = {'url': reverse('api:project_list') + "?scm_type=svn", 'label': 'Subversion', 'failures_url': reverse('api:project_list') + "?scm_type=svn&last_job_failed=True", 'total': svn_projects.count(), 'failed': svn_failed_projects.count()} data['scm_types']['hg'] = {'url': reverse('api:project_list') + "?scm_type=hg", 'label': 'Mercurial', 'failures_url': reverse('api:project_list') + "?scm_type=hg&last_job_failed=True", 'total': hg_projects.count(), 'failed': hg_failed_projects.count()} user_jobs = get_user_queryset(request.user, Job) user_failed_jobs = user_jobs.filter(failed=True) data['jobs'] = {'url': reverse('api:job_list'), 'failure_url': reverse('api:job_list') + "?failed=True", 'total': user_jobs.count(), 'failed': user_failed_jobs.count()} user_list = get_user_queryset(request.user, User) team_list = get_user_queryset(request.user, Team) credential_list = get_user_queryset(request.user, Credential) job_template_list = get_user_queryset(request.user, JobTemplate) organization_list = get_user_queryset(request.user, Organization) data['users'] = {'url': reverse('api:user_list'), 'total': user_list.count()} data['organizations'] = {'url': reverse('api:organization_list'), 'total': organization_list.count()} data['teams'] = {'url': reverse('api:team_list'), 'total': team_list.count()} data['credentials'] = {'url': reverse('api:credential_list'), 'total': credential_list.count()} data['job_templates'] = {'url': reverse('api:job_template_list'), 'total': job_template_list.count()} return Response(data) class DashboardJobsGraphView(APIView): view_name = "Dashboard Jobs Graphs" new_in_200 = True def get(self, request, format=None): period = request.query_params.get('period', 'month') job_type = request.query_params.get('job_type', 'all') user_unified_jobs = get_user_queryset(request.user, UnifiedJob) success_query = user_unified_jobs.filter(status='successful') failed_query = user_unified_jobs.filter(status='failed') if job_type == 'inv_sync': success_query = success_query.filter(instance_of=InventoryUpdate) failed_query = failed_query.filter(instance_of=InventoryUpdate) elif job_type == 'playbook_run': success_query = success_query.filter(instance_of=Job) failed_query = failed_query.filter(instance_of=Job) elif job_type == 'scm_update': success_query = success_query.filter(instance_of=ProjectUpdate) failed_query = failed_query.filter(instance_of=ProjectUpdate) success_qss = qsstats.QuerySetStats(success_query, 'finished') failed_qss = qsstats.QuerySetStats(failed_query, 'finished') start_date = datetime.datetime.utcnow() if period == 'month': end_date = start_date - dateutil.relativedelta.relativedelta(months=1) interval = 'days' elif period == 'week': end_date = start_date - dateutil.relativedelta.relativedelta(weeks=1) interval = 'days' elif period == 'day': end_date = start_date - dateutil.relativedelta.relativedelta(days=1) interval = 'hours' else: return Response({'error': 'Unknown period "%s"' % str(period)}, status=status.HTTP_400_BAD_REQUEST) dashboard_data = {"jobs": {"successful": [], "failed": []}} for element in success_qss.time_series(end_date, start_date, interval=interval): dashboard_data['jobs']['successful'].append([time.mktime(element[0].timetuple()), element[1]]) for element in failed_qss.time_series(end_date, start_date, interval=interval): dashboard_data['jobs']['failed'].append([time.mktime(element[0].timetuple()), element[1]]) return Response(dashboard_data) class DashboardInventoryGraphView(APIView): view_name = "Dashboard Inventory Graphs" new_in_200 = True def get(self, request, format=None): period = request.query_params.get('period', 'month') end_date = now() if period == 'month': start_date = end_date - dateutil.relativedelta.relativedelta(months=1) start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) delta = dateutil.relativedelta.relativedelta(days=1) elif period == 'week': start_date = end_date - dateutil.relativedelta.relativedelta(weeks=1) start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) delta = dateutil.relativedelta.relativedelta(days=1) elif period == 'day': start_date = end_date - dateutil.relativedelta.relativedelta(days=1) start_date = start_date.replace(minute=0, second=0, microsecond=0) delta = dateutil.relativedelta.relativedelta(hours=1) else: raise ParseError(u'Unknown period "%s"' % force_text(period)) host_stats = [] date = start_date while date < end_date: next_date = date + delta # Find all hosts that existed at end of intevral that are still # active or were deleted after the end of interval. Slow but # accurate; haven't yet found a better way to do it. hosts_qs = Host.objects.filter(created__lt=next_date) hosts_qs = hosts_qs.filter(Q(active=True) | Q(active=False, modified__gte=next_date)) hostnames = set() for name, active in hosts_qs.values_list('name', 'active').iterator(): if not active: name = re.sub(r'^_deleted_.*?_', '', name) hostnames.add(name) host_stats.append((time.mktime(date.timetuple()), len(hostnames))) date = next_date return Response({'hosts': host_stats}) class ScheduleList(ListAPIView): view_name = "Schedules" model = Schedule serializer_class = ScheduleSerializer new_in_148 = True class ScheduleDetail(RetrieveUpdateDestroyAPIView): model = Schedule serializer_class = ScheduleSerializer new_in_148 = True class ScheduleUnifiedJobsList(SubListAPIView): model = UnifiedJob serializer_class = UnifiedJobSerializer parent_model = Schedule relationship = 'unifiedjob_set' view_name = 'Schedule Jobs List' new_in_148 = True class AuthView(APIView): authentication_classes = [] permission_classes = (AllowAny,) new_in_240 = True def get(self, request): data = OrderedDict() err_backend, err_message = request.session.get('social_auth_error', (None, None)) auth_backends = load_backends(settings.AUTHENTICATION_BACKENDS).items() # Return auth backends in consistent order: Google, GitHub, SAML. auth_backends.sort(key=lambda x: 'g' if x[0] == 'google-oauth2' else x[0]) for name, backend in auth_backends: if (not feature_exists('enterprise_auth') and not feature_enabled('ldap')) or \ (not feature_enabled('enterprise_auth') and name in ['saml', 'radius']): continue login_url = reverse('social:begin', args=(name,)) complete_url = request.build_absolute_uri(reverse('social:complete', args=(name,))) backend_data = { 'login_url': login_url, 'complete_url': complete_url, } if name == 'saml': backend_data['metadata_url'] = reverse('sso:saml_metadata') for idp in sorted(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.keys()): saml_backend_data = dict(backend_data.items()) saml_backend_data['login_url'] = '%s?idp=%s' % (login_url, idp) full_backend_name = '%s:%s' % (name, idp) if err_backend == full_backend_name and err_message: saml_backend_data['error'] = err_message data[full_backend_name] = saml_backend_data else: if err_backend == name and err_message: backend_data['error'] = err_message data[name] = backend_data return Response(data) class AuthTokenView(APIView): authentication_classes = [] permission_classes = (AllowAny,) serializer_class = AuthTokenSerializer model = AuthToken def post(self, request): serializer = self.serializer_class(data=request.data) if serializer.is_valid(): request_hash = AuthToken.get_request_hash(self.request) try: token = AuthToken.objects.filter(user=serializer.validated_data['user'], request_hash=request_hash, expires__gt=now(), reason='')[0] token.refresh() except IndexError: token = AuthToken.objects.create(user=serializer.validated_data['user'], request_hash=request_hash) # Get user un-expired tokens that are not invalidated that are # over the configured limit. # Mark them as invalid and inform the user invalid_tokens = AuthToken.get_tokens_over_limit(serializer.validated_data['user']) for t in invalid_tokens: # TODO: send socket notification emit_websocket_notification('/socket.io/control', 'limit_reached', dict(reason=force_text(AuthToken.reason_long('limit_reached'))), token_key=t.key) t.invalidate(reason='limit_reached') # Note: This header is normally added in the middleware whenever an # auth token is included in the request header. headers = { 'Auth-Token-Timeout': int(tower_settings.AUTH_TOKEN_EXPIRATION) } return Response({'token': token.key, 'expires': token.expires}, headers=headers) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class OrganizationList(ListCreateAPIView): model = Organization serializer_class = OrganizationSerializer def create(self, request, *args, **kwargs): """Create a new organzation. If there is already an organization and the license of the Tower instance does not permit multiple organizations, then raise LicenseForbids. """ # Sanity check: If the multiple organizations feature is disallowed # by the license, then we are only willing to create this organization # if no organizations exist in the system. if (not feature_enabled('multiple_organizations') and self.model.objects.filter(active=True).count() > 0): raise LicenseForbids('Your Tower license only permits a single ' 'organization to exist.') # Okay, create the organization as usual. return super(OrganizationList, self).create(request, *args, **kwargs) class OrganizationDetail(RetrieveUpdateDestroyAPIView): model = Organization serializer_class = OrganizationSerializer class OrganizationInventoriesList(SubListAPIView): model = Inventory serializer_class = InventorySerializer parent_model = Organization relationship = 'inventories' class OrganizationUsersList(SubListCreateAttachDetachAPIView): model = User serializer_class = UserSerializer parent_model = Organization relationship = 'users' class OrganizationAdminsList(SubListCreateAttachDetachAPIView): model = User serializer_class = UserSerializer parent_model = Organization relationship = 'admins' class OrganizationProjectsList(SubListCreateAttachDetachAPIView): model = Project serializer_class = ProjectSerializer parent_model = Organization relationship = 'projects' class OrganizationTeamsList(SubListCreateAttachDetachAPIView): model = Team serializer_class = TeamSerializer parent_model = Organization relationship = 'teams' parent_key = 'organization' class OrganizationActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Organization relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class TeamList(ListCreateAPIView): model = Team serializer_class = TeamSerializer class TeamDetail(RetrieveUpdateDestroyAPIView): model = Team serializer_class = TeamSerializer class TeamUsersList(SubListCreateAttachDetachAPIView): model = User serializer_class = UserSerializer parent_model = Team relationship = 'users' class TeamPermissionsList(SubListCreateAttachDetachAPIView): model = Permission serializer_class = PermissionSerializer parent_model = Team relationship = 'permissions' parent_key = 'team' def get_queryset(self): # FIXME: Default get_queryset should handle this. team = Team.objects.get(pk=self.kwargs['pk']) base = Permission.objects.filter(team = team) #if Team.can_user_administrate(self.request.user, team, None): if self.request.user.can_access(Team, 'change', team, None): return base elif team.users.filter(pk=self.request.user.pk).count() > 0: return base raise PermissionDenied() class TeamProjectsList(SubListCreateAttachDetachAPIView): model = Project serializer_class = ProjectSerializer parent_model = Team relationship = 'projects' class TeamCredentialsList(SubListCreateAttachDetachAPIView): model = Credential serializer_class = CredentialSerializer parent_model = Team relationship = 'credentials' parent_key = 'team' class TeamActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Team relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(Q(team=parent) | Q(project__in=parent.projects.all()) | Q(credential__in=parent.credentials.all()) | Q(permission__in=parent.permissions.all())) class ProjectList(ListCreateAPIView): model = Project serializer_class = ProjectSerializer def get(self, request, *args, **kwargs): # Not optimal, but make sure the project status and last_updated fields # are up to date here... projects_qs = Project.objects.filter(active=True) projects_qs = projects_qs.select_related('current_job', 'last_job') for project in projects_qs: project._set_status_and_last_job_run() return super(ProjectList, self).get(request, *args, **kwargs) class ProjectDetail(RetrieveUpdateDestroyAPIView): model = Project serializer_class = ProjectSerializer def destroy(self, request, *args, **kwargs): obj = self.get_object() can_delete = request.user.can_access(Project, 'delete', obj) if not can_delete: raise PermissionDenied("Cannot delete project") for pu in obj.project_updates.filter(status__in=['new', 'pending', 'waiting', 'running']): pu.cancel() return super(ProjectDetail, self).destroy(request, *args, **kwargs) class ProjectPlaybooks(RetrieveAPIView): model = Project serializer_class = ProjectPlaybooksSerializer class ProjectOrganizationsList(SubListCreateAttachDetachAPIView): model = Organization serializer_class = OrganizationSerializer parent_model = Project relationship = 'organizations' class ProjectTeamsList(SubListCreateAttachDetachAPIView): model = Team serializer_class = TeamSerializer parent_model = Project relationship = 'teams' class ProjectSchedulesList(SubListCreateAttachDetachAPIView): view_name = "Project Schedules" model = Schedule serializer_class = ScheduleSerializer parent_model = Project relationship = 'schedules' parent_key = 'unified_job_template' new_in_148 = True class ProjectActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Project relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) if parent is None: return qs elif parent.credential is None: return qs.filter(project=parent) return qs.filter(Q(project=parent) | Q(credential__in=parent.credential)) class ProjectUpdatesList(SubListAPIView): model = ProjectUpdate serializer_class = ProjectUpdateSerializer parent_model = Project relationship = 'project_updates' new_in_13 = True class ProjectUpdateView(RetrieveAPIView): model = Project serializer_class = ProjectUpdateViewSerializer new_in_13 = True def post(self, request, *args, **kwargs): obj = self.get_object() if obj.can_update: project_update = obj.update() if not project_update: return Response({}, status=status.HTTP_400_BAD_REQUEST) else: headers = {'Location': project_update.get_absolute_url()} return Response({'project_update': project_update.id}, headers=headers, status=status.HTTP_202_ACCEPTED) else: return self.http_method_not_allowed(request, *args, **kwargs) class ProjectUpdateDetail(RetrieveDestroyAPIView): model = ProjectUpdate serializer_class = ProjectUpdateSerializer new_in_13 = True class ProjectUpdateCancel(RetrieveAPIView): model = ProjectUpdate serializer_class = ProjectUpdateCancelSerializer is_job_cancel = True new_in_13 = 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 UserList(ListCreateAPIView): model = User serializer_class = UserSerializer class UserMeList(ListAPIView): model = User serializer_class = UserSerializer view_name = 'Me' def get_queryset(self): return self.model.objects.filter(pk=self.request.user.pk) class UserTeamsList(SubListAPIView): model = Team serializer_class = TeamSerializer parent_model = User relationship = 'teams' class UserPermissionsList(SubListCreateAttachDetachAPIView): model = Permission serializer_class = PermissionSerializer parent_model = User relationship = 'permissions' parent_key = 'user' class UserProjectsList(SubListAPIView): model = Project serializer_class = ProjectSerializer parent_model = User relationship = 'projects' def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(teams__in=parent.teams.distinct()) class UserCredentialsList(SubListCreateAttachDetachAPIView): model = Credential serializer_class = CredentialSerializer parent_model = User relationship = 'credentials' parent_key = 'user' class UserOrganizationsList(SubListAPIView): model = Organization serializer_class = OrganizationSerializer parent_model = User relationship = 'organizations' class UserAdminOfOrganizationsList(SubListAPIView): model = Organization serializer_class = OrganizationSerializer parent_model = User relationship = 'admin_of_organizations' class UserActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = User relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(Q(actor=parent) | Q(user__in=[parent])) class UserDetail(RetrieveUpdateDestroyAPIView): model = User serializer_class = UserSerializer def update_filter(self, request, *args, **kwargs): ''' make sure non-read-only fields that can only be edited by admins, are only edited by admins ''' obj = self.get_object() can_change = request.user.can_access(User, 'change', obj, request.data) can_admin = request.user.can_access(User, 'admin', obj, request.data) if can_change and not can_admin: admin_only_edit_fields = ('last_name', 'first_name', 'username', 'is_active', 'is_superuser') changed = {} for field in admin_only_edit_fields: left = getattr(obj, field, None) right = request.data.get(field, None) if left is not None and right is not None and left != right: changed[field] = (left, right) if changed: raise PermissionDenied('Cannot change %s' % ', '.join(changed.keys())) def destroy(self, request, *args, **kwargs): obj = self.get_object() can_delete = request.user.can_access(User, 'delete', obj) if not can_delete: raise PermissionDenied('Cannot delete user') for own_credential in Credential.objects.filter(user=obj): own_credential.mark_inactive() return super(UserDetail, self).destroy(request, *args, **kwargs) class CredentialList(ListCreateAPIView): model = Credential serializer_class = CredentialSerializer class CredentialDetail(RetrieveUpdateDestroyAPIView): model = Credential serializer_class = CredentialSerializer class CredentialActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Credential relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class PermissionDetail(RetrieveUpdateDestroyAPIView): model = Permission serializer_class = PermissionSerializer class InventoryScriptList(ListCreateAPIView): model = CustomInventoryScript serializer_class = CustomInventoryScriptSerializer class InventoryScriptDetail(RetrieveUpdateDestroyAPIView): model = CustomInventoryScript serializer_class = CustomInventoryScriptSerializer def destroy(self, request, *args, **kwargs): instance = self.get_object() can_delete = request.user.can_access(self.model, 'delete', instance) if not can_delete: raise PermissionDenied("Cannot delete inventory script") for inv_src in InventorySource.objects.filter(source_script=instance): inv_src.source_script = None inv_src.save() return super(InventoryScriptDetail, self).destroy(request, *args, **kwargs) class InventoryList(ListCreateAPIView): model = Inventory serializer_class = InventorySerializer class InventoryDetail(RetrieveUpdateDestroyAPIView): model = Inventory serializer_class = InventoryDetailSerializer def destroy(self, request, *args, **kwargs): with ignore_inventory_computed_fields(): with ignore_inventory_group_removal(): return super(InventoryDetail, self).destroy(request, *args, **kwargs) class InventoryActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Inventory relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(Q(inventory=parent) | Q(host__in=parent.hosts.all()) | Q(group__in=parent.groups.all())) class InventoryJobTemplateList(SubListAPIView): model = JobTemplate serializer_class = JobTemplateSerializer parent_model = Inventory relationship = 'jobtemplates' new_in_300 = True def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(inventory=parent) class InventoryScanJobTemplateList(SubListAPIView): model = JobTemplate serializer_class = JobTemplateSerializer parent_model = Inventory relationship = 'jobtemplates' new_in_220 = True def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(job_type=PERM_INVENTORY_SCAN, inventory=parent) class InventorySingleFactView(MongoAPIView): model = Fact parent_model = Inventory new_in_220 = True serializer_class = FactSerializer filter_backends = (MongoFilterBackend,) def get(self, request, *args, **kwargs): # Sanity check: Does the license allow system tracking? if not feature_enabled('system_tracking'): raise LicenseForbids('Your license does not permit use ' 'of system tracking.') fact_key = request.query_params.get("fact_key", None) fact_value = request.query_params.get("fact_value", None) datetime_spec = request.query_params.get("timestamp", None) module_spec = request.query_params.get("module", None) if fact_key is None or fact_value is None or module_spec is None: return Response({"error": "Missing fields"}, status=status.HTTP_400_BAD_REQUEST) datetime_actual = dateutil.parser.parse(datetime_spec) if datetime_spec is not None else now() inventory_obj = self.get_parent_object() fact_data = Fact.get_single_facts([h.name for h in inventory_obj.hosts.all()], fact_key, fact_value, datetime_actual, module_spec) return Response(dict(results=FactSerializer(fact_data).data if fact_data is not None else [])) class HostList(ListCreateAPIView): model = Host serializer_class = HostSerializer class HostDetail(RetrieveUpdateDestroyAPIView): model = Host serializer_class = HostSerializer class InventoryHostsList(SubListCreateAttachDetachAPIView): model = Host serializer_class = HostSerializer parent_model = Inventory relationship = 'hosts' parent_key = 'inventory' class HostGroupsList(SubListCreateAttachDetachAPIView): ''' the list of groups a host is directly a member of ''' model = Group serializer_class = GroupSerializer parent_model = Host relationship = 'groups' class HostAllGroupsList(SubListAPIView): ''' the list of all groups of which the host is directly or indirectly a member ''' model = Group serializer_class = GroupSerializer parent_model = Host relationship = 'groups' def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) sublist_qs = parent.all_groups.distinct() return qs & sublist_qs class HostInventorySourcesList(SubListAPIView): model = InventorySource serializer_class = InventorySourceSerializer parent_model = Host relationship = 'inventory_sources' new_in_148 = True class HostActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Host relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(Q(host=parent) | Q(inventory=parent.inventory)) class HostFactVersionsList(MongoListAPIView): serializer_class = FactVersionSerializer parent_model = Host new_in_220 = True filter_backends = (MongoFilterBackend,) def get_queryset(self): from_spec = self.request.query_params.get('from', None) to_spec = self.request.query_params.get('to', None) module_spec = self.request.query_params.get('module', None) if not feature_enabled("system_tracking"): raise LicenseForbids("Your license does not permit use " "of system tracking.") host = self.get_parent_object() self.check_parent_access(host) try: fact_host = FactHost.objects.get(hostname=host.name, inventory_id=host.inventory.pk) except FactHost.DoesNotExist: return None except mongoengine.ConnectionError: return Response(dict(error="System Tracking Database is disabled"), status=status.HTTP_400_BAD_REQUEST) kv = { 'host': fact_host.id, } if module_spec is not None: kv['module'] = module_spec if from_spec is not None: from_actual = dateutil.parser.parse(from_spec) kv['timestamp__gt'] = from_actual if to_spec is not None: to_actual = dateutil.parser.parse(to_spec) kv['timestamp__lte'] = to_actual return FactVersion.objects.filter(**kv).order_by("-timestamp") def list(self, *args, **kwargs): queryset = self.get_queryset() or [] try: serializer = FactVersionSerializer(queryset, many=True, context=dict(host_obj=self.get_parent_object())) except mongoengine.ConnectionError: return Response(dict(error="System Tracking Database is disabled"), status=status.HTTP_400_BAD_REQUEST) return Response(dict(results=serializer.data)) class HostSingleFactView(MongoAPIView): model = Fact parent_model = Host new_in_220 = True serializer_class = FactSerializer filter_backends = (MongoFilterBackend,) def get(self, request, *args, **kwargs): # Sanity check: Does the license allow system tracking? if not feature_enabled('system_tracking'): raise LicenseForbids('Your license does not permit use ' 'of system tracking.') fact_key = request.query_params.get("fact_key", None) fact_value = request.query_params.get("fact_value", None) datetime_spec = request.query_params.get("timestamp", None) module_spec = request.query_params.get("module", None) if fact_key is None or fact_value is None or module_spec is None: return Response({"error": "Missing fields"}, status=status.HTTP_400_BAD_REQUEST) datetime_actual = dateutil.parser.parse(datetime_spec) if datetime_spec is not None else now() host_obj = self.get_parent_object() fact_data = Fact.get_single_facts([host_obj.name], fact_key, fact_value, datetime_actual, module_spec) return Response(dict(results=FactSerializer(fact_data).data if fact_data is not None else [])) class HostFactCompareView(MongoAPIView): new_in_220 = True parent_model = Host serializer_class = FactSerializer filter_backends = (MongoFilterBackend,) def get(self, request, *args, **kwargs): # Sanity check: Does the license allow system tracking? if not feature_enabled('system_tracking'): raise LicenseForbids('Your license does not permit use ' 'of system tracking.') datetime_spec = request.query_params.get('datetime', None) module_spec = request.query_params.get('module', "ansible") datetime_actual = dateutil.parser.parse(datetime_spec) if datetime_spec is not None else now() host_obj = self.get_parent_object() fact_entry = Fact.get_host_version(host_obj.name, host_obj.inventory.pk, datetime_actual, module_spec) host_data = FactSerializer(fact_entry).data if fact_entry is not None else {} return Response(host_data) class GroupList(ListCreateAPIView): model = Group serializer_class = GroupSerializer class GroupChildrenList(SubListCreateAttachDetachAPIView): model = Group serializer_class = GroupSerializer parent_model = Group relationship = 'children' def unattach(self, request, *args, **kwargs): sub_id = request.data.get('id', None) if sub_id is not None: return super(GroupChildrenList, self).unattach(request, *args, **kwargs) parent = self.get_parent_object() parent.mark_inactive() return Response(status=status.HTTP_204_NO_CONTENT) def _unattach(self, request, *args, **kwargs): # FIXME: Disabled for now for UI support. ''' Special case for disassociating a child group from the parent. If the child group has no more parents, then automatically mark it inactive. ''' sub_id = request.data.get('id', None) if not sub_id: data = dict(msg='"id" is required to disassociate') return Response(data, status=status.HTTP_400_BAD_REQUEST) parent = self.get_parent_object() # TODO: flake8 warns, pending removal if unneeded # parent_key = getattr(self, 'parent_key', None) relationship = getattr(parent, self.relationship) sub = get_object_or_400(self.model, pk=sub_id) if not request.user.can_access(self.parent_model, 'unattach', parent, sub, self.relationship): raise PermissionDenied() if sub.parents.filter(active=True).exclude(pk=parent.pk).count() == 0: sub.mark_inactive() else: relationship.remove(sub) return Response(status=status.HTTP_204_NO_CONTENT) class GroupPotentialChildrenList(SubListAPIView): model = Group serializer_class = GroupSerializer parent_model = Group new_in_14 = True def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) qs = qs.filter(inventory__pk=parent.inventory.pk) except_pks = set([parent.pk]) except_pks.update(parent.all_parents.values_list('pk', flat=True)) except_pks.update(parent.all_children.values_list('pk', flat=True)) return qs.exclude(pk__in=except_pks) class GroupHostsList(SubListCreateAttachDetachAPIView): ''' the list of hosts directly below a group ''' model = Host serializer_class = HostSerializer parent_model = Group relationship = 'hosts' def create(self, request, *args, **kwargs): parent_group = Group.objects.get(id=self.kwargs['pk']) existing_hosts = Host.objects.filter(inventory=parent_group.inventory, name=request.data['name']) if existing_hosts.count() > 0 and ('variables' not in request.data or request.data['variables'] == '' or request.data['variables'] == '{}' or request.data['variables'] == '---'): request.data['id'] = existing_hosts[0].id return self.attach(request, *args, **kwargs) return super(GroupHostsList, self).create(request, *args, **kwargs) class GroupAllHostsList(SubListAPIView): ''' the list of all hosts below a group, even including subgroups ''' model = Host serializer_class = HostSerializer parent_model = Group relationship = 'hosts' def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) sublist_qs = parent.all_hosts.distinct() return qs & sublist_qs class GroupInventorySourcesList(SubListAPIView): model = InventorySource serializer_class = InventorySourceSerializer parent_model = Group relationship = 'inventory_sources' new_in_148 = True class GroupActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Group relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(Q(group=parent) | Q(host__in=parent.hosts.all())) class GroupDetail(RetrieveUpdateDestroyAPIView): model = Group serializer_class = GroupSerializer def destroy(self, request, *args, **kwargs): obj = self.get_object() # FIXME: Why isn't the active check being caught earlier by RBAC? if not getattr(obj, 'active', True): raise Http404() if not getattr(obj, 'is_active', True): raise Http404() if not request.user.can_access(self.model, 'delete', obj): raise PermissionDenied() if hasattr(obj, 'mark_inactive'): obj.mark_inactive_recursive() return Response(status=status.HTTP_204_NO_CONTENT) class GroupSingleFactView(MongoAPIView): model = Fact parent_model = Group new_in_220 = True serializer_class = FactSerializer filter_backends = (MongoFilterBackend,) def get(self, request, *args, **kwargs): # Sanity check: Does the license allow system tracking? if not feature_enabled('system_tracking'): raise LicenseForbids('Your license does not permit use ' 'of system tracking.') fact_key = request.query_params.get("fact_key", None) fact_value = request.query_params.get("fact_value", None) datetime_spec = request.query_params.get("timestamp", None) module_spec = request.query_params.get("module", None) if fact_key is None or fact_value is None or module_spec is None: return Response({"error": "Missing fields"}, status=status.HTTP_400_BAD_REQUEST) datetime_actual = dateutil.parser.parse(datetime_spec) if datetime_spec is not None else now() group_obj = self.get_parent_object() fact_data = Fact.get_single_facts([h.name for h in group_obj.hosts.all()], fact_key, fact_value, datetime_actual, module_spec) return Response(dict(results=FactSerializer(fact_data).data if fact_data is not None else [])) class InventoryGroupsList(SubListCreateAttachDetachAPIView): model = Group serializer_class = GroupSerializer parent_model = Inventory relationship = 'groups' parent_key = 'inventory' class InventoryRootGroupsList(SubListCreateAttachDetachAPIView): model = Group serializer_class = GroupSerializer parent_model = Inventory relationship = 'groups' parent_key = 'inventory' def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs & parent.root_groups class BaseVariableData(RetrieveUpdateAPIView): parser_classes = api_settings.DEFAULT_PARSER_CLASSES + [YAMLParser] renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [YAMLRenderer] is_variable_data = True # Special flag for permissions check. class InventoryVariableData(BaseVariableData): model = Inventory serializer_class = InventoryVariableDataSerializer class HostVariableData(BaseVariableData): model = Host serializer_class = HostVariableDataSerializer class GroupVariableData(BaseVariableData): model = Group serializer_class = GroupVariableDataSerializer class InventoryScriptView(RetrieveAPIView): model = Inventory serializer_class = InventoryScriptSerializer authentication_classes = [TaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES permission_classes = (TaskPermission,) filter_backends = () def retrieve(self, request, *args, **kwargs): obj = self.get_object() hostname = request.query_params.get('host', '') hostvars = bool(request.query_params.get('hostvars', '')) show_all = bool(request.query_params.get('all', '')) if show_all: hosts_q = dict(active=True) else: hosts_q = dict(active=True, enabled=True) if hostname: host = get_object_or_404(obj.hosts, name=hostname, **hosts_q) data = host.variables_dict else: data = OrderedDict() if obj.variables_dict: all_group = data.setdefault('all', OrderedDict()) all_group['vars'] = obj.variables_dict # Add hosts without a group to the all group. groupless_hosts_qs = obj.hosts.filter(groups__isnull=True, **hosts_q).order_by('name') groupless_hosts = list(groupless_hosts_qs.values_list('name', flat=True)) if groupless_hosts: all_group = data.setdefault('all', OrderedDict()) all_group['hosts'] = groupless_hosts # Build in-memory mapping of groups and their hosts. group_hosts_kw = dict(group__inventory_id=obj.id, group__active=True, host__inventory_id=obj.id, host__active=True) if 'enabled' in hosts_q: group_hosts_kw['host__enabled'] = hosts_q['enabled'] group_hosts_qs = Group.hosts.through.objects.filter(**group_hosts_kw) group_hosts_qs = group_hosts_qs.order_by('host__name') group_hosts_qs = group_hosts_qs.values_list('group_id', 'host_id', 'host__name') group_hosts_map = {} for group_id, host_id, host_name in group_hosts_qs: group_hostnames = group_hosts_map.setdefault(group_id, []) group_hostnames.append(host_name) # Build in-memory mapping of groups and their children. group_parents_qs = Group.parents.through.objects.filter( from_group__inventory_id=obj.id, from_group__active=True, to_group__inventory_id=obj.id, to_group__active=True, ) group_parents_qs = group_parents_qs.order_by('from_group__name') group_parents_qs = group_parents_qs.values_list('from_group_id', 'from_group__name', 'to_group_id') group_children_map = {} for from_group_id, from_group_name, to_group_id in group_parents_qs: group_children = group_children_map.setdefault(to_group_id, []) group_children.append(from_group_name) # Now use in-memory maps to build up group info. for group in obj.groups.filter(active=True): group_info = OrderedDict() group_info['hosts'] = group_hosts_map.get(group.id, []) group_info['children'] = group_children_map.get(group.id, []) group_info['vars'] = group.variables_dict data[group.name] = group_info if hostvars: data.setdefault('_meta', OrderedDict()) data['_meta'].setdefault('hostvars', OrderedDict()) for host in obj.hosts.filter(**hosts_q): data['_meta']['hostvars'][host.name] = host.variables_dict # workaround for Ansible inventory bug (github #3687), localhost # must be explicitly listed in the all group for dynamic inventory # scripts to pick it up. localhost_names = ('localhost', '127.0.0.1', '::1') localhosts_qs = obj.hosts.filter(name__in=localhost_names, **hosts_q) localhosts = list(localhosts_qs.values_list('name', flat=True)) if localhosts: all_group = data.setdefault('all', OrderedDict()) all_group_hosts = all_group.get('hosts', []) all_group_hosts.extend(localhosts) all_group['hosts'] = sorted(set(all_group_hosts)) return Response(data) class InventoryTreeView(RetrieveAPIView): model = Inventory serializer_class = GroupTreeSerializer filter_backends = () new_in_13 = True def _populate_group_children(self, group_data, all_group_data_map, group_children_map): if 'children' in group_data: return group_data['children'] = [] for child_id in group_children_map.get(group_data['id'], set()): group_data['children'].append(all_group_data_map[child_id]) group_data['children'].sort(key=lambda x: x['name']) for child_data in group_data['children']: self._populate_group_children(child_data, all_group_data_map, group_children_map) def retrieve(self, request, *args, **kwargs): inventory = self.get_object() group_children_map = inventory.get_group_children_map(active=True) root_group_pks = inventory.root_groups.filter(active=True).order_by('name').values_list('pk', flat=True) groups_qs = inventory.groups.filter(active=True) groups_qs = groups_qs.select_related('inventory') groups_qs = groups_qs.prefetch_related('inventory_source') all_group_data = GroupSerializer(groups_qs, many=True).data all_group_data_map = dict((x['id'], x) for x in all_group_data) tree_data = [all_group_data_map[x] for x in root_group_pks] for group_data in tree_data: self._populate_group_children(group_data, all_group_data_map, group_children_map) return Response(tree_data) class InventoryInventorySourcesList(SubListAPIView): model = InventorySource serializer_class = InventorySourceSerializer parent_model = Inventory relationship = None # Not defined since using get_queryset(). view_name = 'Inventory Source List' new_in_14 = True def get_queryset(self): parent = self.get_parent_object() self.check_parent_access(parent) qs = self.request.user.get_queryset(self.model) return qs.filter(Q(inventory__pk=parent.pk) | Q(group__inventory__pk=parent.pk)) class InventorySourceList(ListAPIView): model = InventorySource serializer_class = InventorySourceSerializer new_in_14 = True class InventorySourceDetail(RetrieveUpdateAPIView): model = InventorySource serializer_class = InventorySourceSerializer new_in_14 = True def destroy(self, request, *args, **kwargs): obj = self.get_object() can_delete = request.user.can_access(InventorySource, 'delete', obj) if not can_delete: raise PermissionDenied("Cannot delete inventory source") for pu in obj.inventory_updates.filter(status__in=['new', 'pending', 'waiting', 'running']): pu.cancel() return super(InventorySourceDetail, self).destroy(request, *args, **kwargs) class InventorySourceSchedulesList(SubListCreateAttachDetachAPIView): view_name = "Inventory Source Schedules" model = Schedule serializer_class = ScheduleSerializer parent_model = InventorySource relationship = 'schedules' parent_key = 'unified_job_template' new_in_148 = True class InventorySourceActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = InventorySource relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class InventorySourceHostsList(SubListAPIView): model = Host serializer_class = HostSerializer parent_model = InventorySource relationship = 'hosts' new_in_148 = True class InventorySourceGroupsList(SubListAPIView): model = Group serializer_class = GroupSerializer parent_model = InventorySource relationship = 'groups' new_in_148 = True class InventorySourceUpdatesList(SubListAPIView): model = InventoryUpdate serializer_class = InventoryUpdateSerializer parent_model = InventorySource relationship = 'inventory_updates' new_in_14 = True class InventorySourceUpdateView(RetrieveAPIView): model = InventorySource serializer_class = InventorySourceUpdateSerializer is_job_start = True new_in_14 = True def post(self, request, *args, **kwargs): obj = self.get_object() if obj.can_update: inventory_update = obj.update() if not inventory_update: return Response({}, status=status.HTTP_400_BAD_REQUEST) else: headers = {'Location': inventory_update.get_absolute_url()} return Response(dict(inventory_update=inventory_update.id), status=status.HTTP_202_ACCEPTED, headers=headers) else: return self.http_method_not_allowed(request, *args, **kwargs) class InventoryUpdateDetail(RetrieveDestroyAPIView): model = InventoryUpdate serializer_class = InventoryUpdateSerializer new_in_14 = True class InventoryUpdateCancel(RetrieveAPIView): model = InventoryUpdate serializer_class = InventoryUpdateCancelSerializer is_job_cancel = True new_in_14 = 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 JobTemplateList(ListCreateAPIView): model = JobTemplate serializer_class = JobTemplateSerializer always_allow_superuser = False class JobTemplateDetail(RetrieveUpdateDestroyAPIView): model = JobTemplate serializer_class = JobTemplateSerializer always_allow_superuser = False def destroy(self, request, *args, **kwargs): obj = self.get_object() can_delete = request.user.can_access(JobTemplate, 'delete', obj) if not can_delete: raise PermissionDenied("Cannot delete job template") for pu in obj.jobs.filter(status__in=['new', 'pending', 'waiting', 'running']): pu.cancel() return super(JobTemplateDetail, self).destroy(request, *args, **kwargs) class JobTemplateLaunch(RetrieveAPIView, GenericAPIView): model = JobTemplate serializer_class = JobLaunchSerializer is_job_start = True always_allow_superuser = False def post(self, request, *args, **kwargs): obj = self.get_object() if not request.user.can_access(self.model, 'start', obj): raise PermissionDenied() if 'credential' not in request.data and 'credential_id' in request.data: request.data['credential'] = request.data['credential_id'] passwords = {} serializer = self.serializer_class(instance=obj, data=request.data, context={'obj': obj, 'data': request.data, 'passwords': passwords}) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) # At this point, a credential is gauranteed to exist at serializer.instance.credential if not request.user.can_access(Credential, 'read', serializer.instance.credential): raise PermissionDenied() kv = { 'credential': serializer.instance.credential.pk, } if 'extra_vars' in request.data: kv['extra_vars'] = request.data['extra_vars'] kv.update(passwords) new_job = obj.create_unified_job(**kv) result = new_job.signal_start(**kv) if not result: data = dict(passwords_needed_to_start=new_job.passwords_needed_to_start) new_job.delete() return Response(data, status=status.HTTP_400_BAD_REQUEST) else: data = dict(job=new_job.id) return Response(data, status=status.HTTP_202_ACCEPTED) class JobTemplateSchedulesList(SubListCreateAttachDetachAPIView): view_name = "Job Template Schedules" model = Schedule serializer_class = ScheduleSerializer parent_model = JobTemplate relationship = 'schedules' parent_key = 'unified_job_template' new_in_148 = True class JobTemplateSurveySpec(GenericAPIView): model = JobTemplate parent_model = JobTemplate # FIXME: Add serializer class to define fields in OPTIONS request! def get(self, request, *args, **kwargs): obj = self.get_object() if not obj.survey_enabled: return Response(status=status.HTTP_404_NOT_FOUND) return Response(obj.survey_spec) def post(self, request, *args, **kwargs): obj = self.get_object() # Sanity check: Are surveys available on this license? # If not, do not allow them to be used. if not feature_enabled('surveys'): raise LicenseForbids('Your license does not allow ' 'adding surveys.') if not request.user.can_access(self.model, 'change', obj, None): raise PermissionDenied() try: obj.survey_spec = json.dumps(request.data) except ValueError: # TODO: Log return Response(dict(error="Invalid JSON when parsing survey spec"), status=status.HTTP_400_BAD_REQUEST) if "name" not in obj.survey_spec: return Response(dict(error="'name' missing from survey spec"), status=status.HTTP_400_BAD_REQUEST) if "description" not in obj.survey_spec: return Response(dict(error="'description' missing from survey spec"), status=status.HTTP_400_BAD_REQUEST) if "spec" not in obj.survey_spec: return Response(dict(error="'spec' missing from survey spec"), status=status.HTTP_400_BAD_REQUEST) if type(obj.survey_spec["spec"]) != list: return Response(dict(error="'spec' must be a list of items"), status=status.HTTP_400_BAD_REQUEST) if len(obj.survey_spec["spec"]) < 1: return Response(dict(error="'spec' doesn't contain any items"), status=status.HTTP_400_BAD_REQUEST) idx = 0 for survey_item in obj.survey_spec["spec"]: if type(survey_item) != dict: return Response(dict(error="survey element %s is not a json object" % str(idx)), status=status.HTTP_400_BAD_REQUEST) if "type" not in survey_item: return Response(dict(error="'type' missing from survey element %s" % str(idx)), status=status.HTTP_400_BAD_REQUEST) if "question_name" not in survey_item: return Response(dict(error="'question_name' missing from survey element %s" % str(idx)), status=status.HTTP_400_BAD_REQUEST) if "variable" not in survey_item: return Response(dict(error="'variable' missing from survey element %s" % str(idx)), status=status.HTTP_400_BAD_REQUEST) if "required" not in survey_item: return Response(dict(error="'required' missing from survey element %s" % str(idx)), status=status.HTTP_400_BAD_REQUEST) idx += 1 obj.save() return Response() def delete(self, request, *args, **kwargs): obj = self.get_object() if not request.user.can_access(self.model, 'delete', obj): raise PermissionDenied() obj.survey_spec = {} obj.save() return Response() class JobTemplateActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = JobTemplate relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class JobTemplateCallback(GenericAPIView): model = JobTemplate # FIXME: Add serializer class to define fields in OPTIONS request! permission_classes = (JobTemplateCallbackPermission,) @csrf_exempt @transaction.non_atomic_requests def dispatch(self, *args, **kwargs): return super(JobTemplateCallback, self).dispatch(*args, **kwargs) def find_matching_hosts(self): ''' Find the host(s) in the job template's inventory that match the remote host for the current request. ''' # Find the list of remote host names/IPs to check. remote_hosts = set() for header in settings.REMOTE_HOST_HEADERS: for value in self.request.META.get(header, '').split(','): value = value.strip() if value: remote_hosts.add(value) # Add the reverse lookup of IP addresses. for rh in list(remote_hosts): try: result = socket.gethostbyaddr(rh) except socket.herror: continue except socket.gaierror: continue remote_hosts.add(result[0]) remote_hosts.update(result[1]) # Filter out any .arpa results. for rh in list(remote_hosts): if rh.endswith('.arpa'): remote_hosts.remove(rh) if not remote_hosts: return set() # Find the host objects to search for a match. obj = self.get_object() qs = obj.inventory.hosts.filter(active=True) # First try for an exact match on the name. try: return set([qs.get(name__in=remote_hosts)]) except (Host.DoesNotExist, Host.MultipleObjectsReturned): pass # Next, try matching based on name or ansible_ssh_host variable. matches = set() for host in qs: ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '') if ansible_ssh_host in remote_hosts: matches.add(host) # FIXME: Not entirely sure if this statement will ever be needed? if host.name != ansible_ssh_host and host.name in remote_hosts: matches.add(host) if len(matches) == 1: return matches # Try to resolve forward addresses for each host to find matches. for host in qs: hostnames = set([host.name]) ansible_ssh_host = host.variables_dict.get('ansible_ssh_host', '') if ansible_ssh_host: hostnames.add(ansible_ssh_host) for hostname in hostnames: try: result = socket.getaddrinfo(hostname, None) possible_ips = set(x[4][0] for x in result) possible_ips.discard(hostname) if possible_ips and possible_ips & remote_hosts: matches.add(host) except socket.gaierror: pass # Return all matches found. return matches def get(self, request, *args, **kwargs): job_template = self.get_object() matching_hosts = self.find_matching_hosts() data = dict( host_config_key=job_template.host_config_key, matching_hosts=[x.name for x in matching_hosts], ) if settings.DEBUG: d = dict([(k,v) for k,v in request.META.items() if k.startswith('HTTP_') or k.startswith('REMOTE_')]) data['request_meta'] = d return Response(data) def post(self, request, *args, **kwargs): extra_vars = None if request.content_type == "application/json": extra_vars = request.data.get("extra_vars", None) # Permission class should have already validated host_config_key. job_template = self.get_object() # Attempt to find matching hosts based on remote address. matching_hosts = self.find_matching_hosts() # If the host is not found, update the inventory before trying to # match again. inventory_sources_already_updated = [] if len(matching_hosts) != 1: inventory_sources = job_template.inventory.inventory_sources.filter(active=True, update_on_launch=True) inventory_update_pks = set() for inventory_source in inventory_sources: if inventory_source.needs_update_on_launch: # FIXME: Doesn't check for any existing updates. inventory_update = inventory_source.create_inventory_update(launch_type='callback') inventory_update.signal_start() inventory_update_pks.add(inventory_update.pk) inventory_update_qs = InventoryUpdate.objects.filter(pk__in=inventory_update_pks, status__in=('pending', 'waiting', 'running')) # Poll for the inventory updates we've started to complete. while inventory_update_qs.count(): time.sleep(1.0) transaction.commit() # Ignore failed inventory updates here, only add successful ones # to the list to be excluded when running the job. for inventory_update in InventoryUpdate.objects.filter(pk__in=inventory_update_pks, status='successful'): inventory_sources_already_updated.append(inventory_update.inventory_source_id) matching_hosts = self.find_matching_hosts() # Check matching hosts. if not matching_hosts: data = dict(msg='No matching host could be found!') # FIXME: Log! return Response(data, status=status.HTTP_400_BAD_REQUEST) elif len(matching_hosts) > 1: data = dict(msg='Multiple hosts matched the request!') # FIXME: Log! return Response(data, status=status.HTTP_400_BAD_REQUEST) else: host = list(matching_hosts)[0] if not job_template.can_start_without_user_input(): data = dict(msg='Cannot start automatically, user input required!') # FIXME: Log! return Response(data, status=status.HTTP_400_BAD_REQUEST) limit = ':&'.join(filter(None, [job_template.limit, host.name])) # NOTE: We limit this to one job waiting due to this: https://trello.com/c/yK36dGWp if Job.objects.filter(status__in=['pending', 'waiting', 'running'], job_template=job_template, limit=limit).count() > 0: data = dict(msg='Host callback job already pending') return Response(data, status=status.HTTP_400_BAD_REQUEST) # Everything is fine; actually create the job. with transaction.atomic(): job = job_template.create_job(limit=limit, launch_type='callback') # Send a signal to celery that the job should be started. kv = {"inventory_sources_already_updated": inventory_sources_already_updated} if extra_vars is not None: kv['extra_vars'] = extra_vars result = job.signal_start(**kv) if not result: data = dict(msg='Error starting job!') return Response(data, status=status.HTTP_400_BAD_REQUEST) # Return the location of the new job. headers = {'Location': job.get_absolute_url()} return Response(status=status.HTTP_202_ACCEPTED, headers=headers) class JobTemplateJobsList(SubListCreateAPIView): model = Job serializer_class = JobListSerializer parent_model = JobTemplate relationship = 'jobs' parent_key = 'job_template' class SystemJobTemplateList(ListAPIView): model = SystemJobTemplate serializer_class = SystemJobTemplateSerializer def get(self, request, *args, **kwargs): if not request.user.is_superuser: return Response(status=status.HTTP_404_NOT_FOUND) return super(SystemJobTemplateList, self).get(request, *args, **kwargs) class SystemJobTemplateDetail(RetrieveAPIView): model = SystemJobTemplate serializer_class = SystemJobTemplateSerializer class SystemJobTemplateLaunch(GenericAPIView): model = SystemJobTemplate # FIXME: Add serializer class to define fields in OPTIONS request! def get(self, request, *args, **kwargs): return Response({}) def post(self, request, *args, **kwargs): obj = self.get_object() if not request.user.can_access(self.model, 'start', obj): raise PermissionDenied() new_job = obj.create_unified_job(**request.data) new_job.signal_start(**request.data) data = dict(system_job=new_job.id) return Response(data, status=status.HTTP_202_ACCEPTED) class SystemJobTemplateSchedulesList(SubListCreateAttachDetachAPIView): view_name = "System Job Template Schedules" model = Schedule serializer_class = ScheduleSerializer parent_model = SystemJobTemplate relationship = 'schedules' parent_key = 'unified_job_template' class SystemJobTemplateJobsList(SubListAPIView): model = SystemJob serializer_class = SystemJobListSerializer parent_model = SystemJobTemplate relationship = 'jobs' parent_key = 'system_job_template' class JobList(ListCreateAPIView): model = Job serializer_class = JobListSerializer class JobDetail(RetrieveUpdateDestroyAPIView): model = Job serializer_class = JobSerializer def update(self, request, *args, **kwargs): obj = self.get_object() # Only allow changes (PUT/PATCH) when job status is "new". if obj.status != 'new': return self.http_method_not_allowed(request, *args, **kwargs) return super(JobDetail, self).update(request, *args, **kwargs) class JobActivityStreamList(SubListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer parent_model = Job relationship = 'activitystream_set' new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class JobStart(GenericAPIView): model = Job # FIXME: Add serializer class to define fields in OPTIONS request! is_job_start = True def get(self, request, *args, **kwargs): obj = self.get_object() data = dict( can_start=obj.can_start, ) if obj.can_start: data['passwords_needed_to_start'] = obj.passwords_needed_to_start data['ask_variables_on_launch'] = obj.ask_variables_on_launch 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() if obj.can_start: result = obj.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: return Response(status=status.HTTP_202_ACCEPTED) else: return self.http_method_not_allowed(request, *args, **kwargs) class JobCancel(RetrieveAPIView): model = Job serializer_class = JobCancelSerializer is_job_cancel = 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 JobRelaunch(RetrieveAPIView, GenericAPIView): model = Job serializer_class = JobRelaunchSerializer is_job_start = True @csrf_exempt @transaction.non_atomic_requests def dispatch(self, *args, **kwargs): return super(JobRelaunch, self).dispatch(*args, **kwargs) def post(self, request, *args, **kwargs): obj = self.get_object() if not request.user.can_access(self.model, 'start', obj): raise PermissionDenied() # Note: is_valid() may modify request.data # It will remove any key/value pair who's key is not in the 'passwords_needed_to_start' list serializer = self.serializer_class(data=request.data, context={'obj': obj, 'data': request.data}) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) obj.launch_type = 'relaunch' new_job = obj.copy() result = new_job.signal_start(**request.data) if not result: data = dict(passwords_needed_to_start=new_job.passwords_needed_to_start) return Response(data, status=status.HTTP_400_BAD_REQUEST) else: data = JobSerializer(new_job).data # Add job key to match what old relaunch returned. data['job'] = new_job.id headers = {'Location': new_job.get_absolute_url()} return Response(data, status=status.HTTP_201_CREATED, headers=headers) class BaseJobHostSummariesList(SubListAPIView): model = JobHostSummary serializer_class = JobHostSummarySerializer parent_model = None # Subclasses must define this attribute. relationship = 'job_host_summaries' view_name = 'Job Host Summaries List' class HostJobHostSummariesList(BaseJobHostSummariesList): parent_model = Host class GroupJobHostSummariesList(BaseJobHostSummariesList): parent_model = Group class JobJobHostSummariesList(BaseJobHostSummariesList): parent_model = Job class JobHostSummaryDetail(RetrieveAPIView): model = JobHostSummary serializer_class = JobHostSummarySerializer class JobEventList(ListAPIView): model = JobEvent serializer_class = JobEventSerializer class JobEventDetail(RetrieveAPIView): model = JobEvent serializer_class = JobEventSerializer class JobEventChildrenList(SubListAPIView): model = JobEvent serializer_class = JobEventSerializer parent_model = JobEvent relationship = 'children' view_name = 'Job Event Children List' class JobEventHostsList(SubListAPIView): model = Host serializer_class = HostSerializer parent_model = JobEvent relationship = 'hosts' view_name = 'Job Event Hosts List' class BaseJobEventsList(SubListAPIView): model = JobEvent serializer_class = JobEventSerializer parent_model = None # Subclasses must define this attribute. relationship = 'job_events' view_name = 'Job Events List' class HostJobEventsList(BaseJobEventsList): parent_model = Host class GroupJobEventsList(BaseJobEventsList): parent_model = Group class JobJobEventsList(BaseJobEventsList): parent_model = Job authentication_classes = [TaskAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES permission_classes = (TaskPermission,) # Post allowed for job 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['job'] = parent_obj.pk serializer = self.get_serializer(data=data) if serializer.is_valid(): self.instance = serializer.save() 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 JobJobPlaysList(BaseJobEventsList): parent_model = Job view_name = 'Job Plays List' new_in_200 = True @paginated def get(self, request, limit, offset, ordering, *args, **kwargs): all_plays = [] job = Job.objects.filter(pk=self.kwargs['pk']) if not job.exists(): return ({'detail': 'job not found'}, -1, status.HTTP_404_NOT_FOUND) job = job[0] # Put together a queryset for relevant job events. qs = job.job_events.filter(event='playbook_on_play_start') if ordering is not None: qs = qs.order_by(ordering) # This is a bit of a special case for filtering requested by the UI # doing this here for the moment until/unless we need to implement more # complex filtering (since we aren't under a serializer) if "id__in" in request.query_params: qs = qs.filter(id__in=[int(filter_id) for filter_id in request.query_params["id__in"].split(",")]) elif "id__gt" in request.query_params: qs = qs.filter(id__gt=request.query_params['id__gt']) elif "id__lt" in request.query_params: qs = qs.filter(id__lt=request.query_params['id__lt']) if "failed" in request.query_params: qs = qs.filter(failed=(request.query_params['failed'].lower() == 'true')) if "play__icontains" in request.query_params: qs = qs.filter(play__icontains=request.query_params['play__icontains']) count = qs.count() # Iterate over the relevant play events and get the details. for play_event in qs[offset:offset + limit]: play_details = dict(id=play_event.id, play=play_event.play, started=play_event.created, failed=play_event.failed, changed=play_event.changed) event_aggregates = JobEvent.objects.filter(parent__in=play_event.children.all()).values("event").annotate(Count("id")).order_by() change_aggregates = JobEvent.objects.filter(parent__in=play_event.children.all(), event='runner_on_ok').values("changed").annotate(Count("id")).order_by() failed_count = 0 ok_count = 0 changed_count = 0 skipped_count = 0 unreachable_count = 0 for event_aggregate in event_aggregates: if event_aggregate['event'] == 'runner_on_failed': failed_count += event_aggregate['id__count'] elif event_aggregate['event'] == 'runner_on_error': failed_count += event_aggregate['id_count'] elif event_aggregate['event'] == 'runner_on_skipped': skipped_count = event_aggregate['id__count'] elif event_aggregate['event'] == 'runner_on_unreachable': unreachable_count = event_aggregate['id__count'] for change_aggregate in change_aggregates: if not change_aggregate['changed']: ok_count = change_aggregate['id__count'] else: changed_count = change_aggregate['id__count'] play_details['related'] = {'job_event': reverse('api:job_event_detail', args=(play_event.pk,))} play_details['type'] = 'job_event' play_details['ok_count'] = ok_count play_details['failed_count'] = failed_count play_details['changed_count'] = changed_count play_details['skipped_count'] = skipped_count play_details['unreachable_count'] = unreachable_count all_plays.append(play_details) # Done; return the plays and the total count. return all_plays, count, None class JobJobTasksList(BaseJobEventsList): """A view for displaying aggregate data about tasks within a job and their completion status. """ parent_model = Job view_name = 'Job Play Tasks List' new_in_200 = True @paginated def get(self, request, limit, offset, ordering, *args, **kwargs): """Return aggregate data about each of the job tasks that is: - an immediate child of the job event - corresponding to the spinning up of a new task or playbook """ results = [] # Get the job and the parent task. # If there's no event ID specified, this will return a 404. job = Job.objects.filter(pk=self.kwargs['pk']) if not job.exists(): return ({'detail': 'job not found'}, -1, status.HTTP_404_NOT_FOUND) job = job[0] if 'event_id' not in request.query_params: return ({'detail': '"event_id" not provided'}, -1, status.HTTP_400_BAD_REQUEST) parent_task = job.job_events.filter(pk=int(request.query_params.get('event_id', -1))) if not parent_task.exists(): return ({'detail': 'parent event not found'}, -1, status.HTTP_404_NOT_FOUND) parent_task = parent_task[0] # Some events correspond to a playbook or task starting up, # and these are what we're interested in here. STARTING_EVENTS = ('playbook_on_task_start', 'playbook_on_setup') # We need to pull information about each start event. # # This is super tricky, because this table has a one-to-many # relationship with itself (parent-child), and we're getting # information for an arbitrary number of children. This means we # need stats on grandchildren, sorted by child. queryset = (JobEvent.objects.filter(parent__parent=parent_task, parent__event__in=STARTING_EVENTS) .values('parent__id', 'event', 'changed') .annotate(num=Count('event')) .order_by('parent__id')) # The data above will come back in a list, but we are going to # want to access it based on the parent id, so map it into a # dictionary. data = {} for line in queryset[offset:offset + limit]: parent_id = line.pop('parent__id') data.setdefault(parent_id, []) data[parent_id].append(line) # Iterate over the start events and compile information about each one # using their children. qs = parent_task.children.filter(event__in=STARTING_EVENTS, id__in=data.keys()) # This is a bit of a special case for id filtering requested by the UI # doing this here for the moment until/unless we need to implement more # complex filtering (since we aren't under a serializer) if "id__in" in request.query_params: qs = qs.filter(id__in=[int(filter_id) for filter_id in request.query_params["id__in"].split(",")]) elif "id__gt" in request.query_params: qs = qs.filter(id__gt=request.query_params['id__gt']) elif "id__lt" in request.query_params: qs = qs.filter(id__lt=request.query_params['id__lt']) if "failed" in request.query_params: qs = qs.filter(failed=(request.query_params['failed'].lower() == 'true')) if "task__icontains" in request.query_params: qs = qs.filter(task__icontains=request.query_params['task__icontains']) if ordering is not None: qs = qs.order_by(ordering) count = 0 for task_start_event in qs: # Create initial task data. task_data = { 'related': {'job_event': reverse('api:job_event_detail', args=(task_start_event.pk,))}, 'type': 'job_event', 'changed': task_start_event.changed, 'changed_count': 0, 'created': task_start_event.created, 'failed': task_start_event.failed, 'failed_count': 0, 'host_count': 0, 'id': task_start_event.id, 'modified': task_start_event.modified, 'name': 'Gathering Facts' if task_start_event.event == 'playbook_on_setup' else task_start_event.task, 'reported_hosts': 0, 'skipped_count': 0, 'unreachable_count': 0, 'successful_count': 0, } # Iterate over the data compiled for this child event, and # make appropriate changes to the task data. for child_data in data.get(task_start_event.id, []): if child_data['event'] == 'runner_on_failed': task_data['failed'] = True task_data['host_count'] += child_data['num'] task_data['reported_hosts'] += child_data['num'] task_data['failed_count'] += child_data['num'] elif child_data['event'] == 'runner_on_ok': task_data['host_count'] += child_data['num'] task_data['reported_hosts'] += child_data['num'] if child_data['changed']: task_data['changed_count'] += child_data['num'] task_data['changed'] = True else: task_data['successful_count'] += child_data['num'] elif child_data['event'] == 'runner_on_unreachable': task_data['host_count'] += child_data['num'] task_data['unreachable_count'] += child_data['num'] elif child_data['event'] == 'runner_on_skipped': task_data['host_count'] += child_data['num'] task_data['reported_hosts'] += child_data['num'] task_data['skipped_count'] += child_data['num'] elif child_data['event'] == 'runner_on_error': task_data['host_count'] += child_data['num'] task_data['reported_hosts'] += child_data['num'] task_data['failed'] = True task_data['failed_count'] += child_data['num'] elif child_data['event'] == 'runner_on_no_hosts': task_data['host_count'] += child_data['num'] count += 1 results.append(task_data) # Done; return the results and count. return (results, count, None) class AdHocCommandList(ListCreateAPIView): model = AdHocCommand serializer_class = AdHocCommandListSerializer new_in_220 = True always_allow_superuser = False @csrf_exempt @transaction.non_atomic_requests def dispatch(self, *args, **kwargs): return super(AdHocCommandList, self).dispatch(*args, **kwargs) def create(self, request, *args, **kwargs): # Inject inventory ID and limit 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 data['limit'] = parent_obj.name # Check for passwords needed before creating ad hoc command. credential_pk = get_pk_from_dict(request.data, 'credential') if credential_pk: credential = get_object_or_400(Credential, pk=credential_pk) needed = credential.passwords_needed provided = dict([(field, request.data.get(field, '')) for field in needed]) if not all(provided.values()): data = dict(passwords_needed_to_start=needed) return Response(data, status=status.HTTP_400_BAD_REQUEST) 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(RetrieveDestroyAPIView): 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 serializer_class = AdHocCommandRelaunchSerializer is_job_start = True new_in_220 = True # FIXME: Figure out why OPTIONS request still shows all fields. @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 = dict(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() # Re-validate ad hoc command against serializer to check if module is # still allowed. data = {} for field in ('job_type', 'inventory_id', 'limit', 'credential_id', 'module_name', 'module_args', 'forks', 'verbosity', 'become_enabled'): if field.endswith('_id'): data[field[:-3]] = getattr(obj, field) else: data[field] = getattr(obj, field) serializer = AdHocCommandSerializer(data=data, context=self.get_serializer_context()) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) # Check for passwords needed before copying ad hoc command. needed = obj.passwords_needed_to_start provided = dict([(field, request.data.get(field, '')) for field in needed]) if not all(provided.values()): data = dict(passwords_needed_to_start=needed) return Response(data, status=status.HTTP_400_BAD_REQUEST) # Copy and start the new ad hoc command. new_ad_hoc_command = obj.copy() result = new_ad_hoc_command.signal_start(**request.data) if not result: data = dict(passwords_needed_to_start=new_ad_hoc_command.passwords_needed_to_start) return Response(data, status=status.HTTP_400_BAD_REQUEST) else: data = AdHocCommandSerializer(new_ad_hoc_command).data # Add ad_hoc_command key to match what was previously returned. data['ad_hoc_command'] = new_ad_hoc_command.id headers = {'Location': new_ad_hoc_command.get_absolute_url()} return Response(data, status=status.HTTP_201_CREATED, headers=headers) 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): if request.user: raise PermissionDenied() parent_obj = get_object_or_404(self.parent_model, pk=self.kwargs['pk']) data = request.data.copy() data['ad_hoc_command'] = parent_obj serializer = self.get_serializer(data=data) if serializer.is_valid(): self.instance = serializer.save() 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 def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class SystemJobList(ListCreateAPIView): model = SystemJob serializer_class = SystemJobListSerializer def get(self, request, *args, **kwargs): if not request.user.is_superuser: return Response(status=status.HTTP_404_NOT_FOUND) return super(SystemJobList, self).get(request, *args, **kwargs) class SystemJobDetail(RetrieveDestroyAPIView): model = SystemJob serializer_class = SystemJobSerializer class SystemJobCancel(RetrieveAPIView): model = SystemJob serializer_class = SystemJobCancelSerializer is_job_cancel = 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 UnifiedJobTemplateList(ListAPIView): model = UnifiedJobTemplate serializer_class = UnifiedJobTemplateSerializer new_in_148 = True class UnifiedJobList(ListAPIView): model = UnifiedJob serializer_class = UnifiedJobListSerializer new_in_148 = True class UnifiedJobStdout(RetrieveAPIView): authentication_classes = [TokenGetAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES serializer_class = UnifiedJobStdoutSerializer renderer_classes = [BrowsableAPIRenderer, renderers.StaticHTMLRenderer, PlainTextRenderer, AnsiTextRenderer, renderers.JSONRenderer, DownloadTextRenderer] filter_backends = () new_in_148 = True def retrieve(self, request, *args, **kwargs): unified_job = self.get_object() obj_size = unified_job.result_stdout_size if request.accepted_renderer.format != 'txt_download' and obj_size > tower_settings.STDOUT_MAX_BYTES_DISPLAY: response_message = "Standard Output too large to display (%d bytes), only download supported for sizes over %d bytes" % (obj_size, tower_settings.STDOUT_MAX_BYTES_DISPLAY) if request.accepted_renderer.format == 'json': return Response({'range': {'start': 0, 'end': 1, 'absolute_end': 1}, 'content': response_message}) else: return Response(response_message) if request.accepted_renderer.format in ('html', 'api', 'json'): content_format = request.query_params.get('content_format', 'html') content_encoding = request.query_params.get('content_encoding', None) start_line = request.query_params.get('start_line', 0) end_line = request.query_params.get('end_line', None) dark_val = request.query_params.get('dark', '') dark = bool(dark_val and dark_val[0].lower() in ('1', 't', 'y')) content_only = bool(request.accepted_renderer.format in ('api', 'json')) dark_bg = (content_only and dark) or (not content_only and (dark or not dark_val)) content, start, end, absolute_end = unified_job.result_stdout_raw_limited(start_line, end_line) body = ansiconv.to_html(cgi.escape(content)) context = { 'title': get_view_name(self.__class__), 'body': mark_safe(body), 'dark': dark_bg, 'content_only': content_only, } data = render_to_string('api/stdout.html', context).strip() if request.accepted_renderer.format == 'api': return Response(mark_safe(data)) if request.accepted_renderer.format == 'json': if content_encoding == 'base64' and content_format == 'ansi': return Response({'range': {'start': start, 'end': end, 'absolute_end': absolute_end}, 'content': b64encode(content)}) elif content_format == 'html': return Response({'range': {'start': start, 'end': end, 'absolute_end': absolute_end}, 'content': body}) return Response(data) elif request.accepted_renderer.format == 'ansi': return Response(unified_job.result_stdout_raw) elif request.accepted_renderer.format == 'txt_download': try: content_fd = open(unified_job.result_stdout_file, 'r') response = HttpResponse(FileWrapper(content_fd), content_type='text/plain') response["Content-Disposition"] = 'attachment; filename="job_%s.txt"' % str(unified_job.id) return response except Exception, e: return Response({"error": "Error generating stdout download file: %s" % str(e)}, status=status.HTTP_400_BAD_REQUEST) elif request.accepted_renderer.format == 'txt': return Response(unified_job.result_stdout) else: return super(UnifiedJobStdout, self).retrieve(request, *args, **kwargs) class ProjectUpdateStdout(UnifiedJobStdout): model = ProjectUpdate class InventoryUpdateStdout(UnifiedJobStdout): model = InventoryUpdate class JobStdout(UnifiedJobStdout): model = Job class AdHocCommandStdout(UnifiedJobStdout): model = AdHocCommand new_in_220 = True class ActivityStreamList(SimpleListAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class ActivityStreamDetail(RetrieveAPIView): model = ActivityStream serializer_class = ActivityStreamSerializer new_in_145 = True def get(self, request, *args, **kwargs): # Sanity check: Does this license allow activity streams? # If not, forbid this request. if not feature_enabled('activity_streams'): raise LicenseForbids('Your license does not allow use of ' 'the activity stream.') # Okay, let it through. return super(type(self), self).get(request, *args, **kwargs) class SettingsList(ListCreateAPIView): model = TowerSettings serializer_class = TowerSettingsSerializer authentication_classes = [TokenGetAuthentication] + api_settings.DEFAULT_AUTHENTICATION_CLASSES new_in_300 = True filter_backends = () def get_queryset(self): class SettingsIntermediary(object): def __init__(self, key, description, category, value, value_type, user=None): self.key = key self.description = description self.category = category self.value = value self.value_type = value_type self.user = user if not self.request.user.is_superuser: # NOTE: Shortcutting the rbac class due to the merging of the settings manifest and the database # we'll need to extend this more in the future when we have user settings return [] all_defined_settings = {} for s in TowerSettings.objects.all(): all_defined_settings[s.key] = SettingsIntermediary(s.key, s.description, s.category, s.value_converted, s.value_type, s.user) manifest_settings = settings.TOWER_SETTINGS_MANIFEST settings_actual = [] for settings_key in manifest_settings: if settings_key in all_defined_settings: settings_actual.append(all_defined_settings[settings_key]) else: m_entry = manifest_settings[settings_key] settings_actual.append(SettingsIntermediary(settings_key, m_entry['description'], m_entry['category'], m_entry['default'], m_entry['type'])) return settings_actual def delete(self, request, *args, **kwargs): if not request.user.can_access(self.model, 'delete', None): raise PermissionDenied() TowerSettings.objects.all().delete() return Response() class SettingsReset(APIView): view_name = "Reset a settings value" new_in_300 = True def post(self, request): # NOTE: Extend more with user settings if not request.user.can_access(TowerSettings, 'delete', None): raise PermissionDenied() settings_key = request.data.get('key', None) if settings_key is not None: TowerSettings.objects.filter(key=settings_key).delete() return Response(status=status.HTTP_204_NO_CONTENT) # Create view functions for all of the class-based views to simplify inclusion # in URL patterns and reverse URL lookups, converting CamelCase names to # lowercase_with_underscore (e.g. MyView.as_view() becomes my_view). this_module = sys.modules[__name__] for attr, value in locals().items(): if isinstance(value, type) and issubclass(value, APIView): name = camelcase_to_underscore(attr) view = value.as_view() setattr(this_module, name, view)