Merge branch 'rbac' of github.com:ansible/ansible-tower into rbac

This commit is contained in:
Wayne Witzel III 2016-03-07 08:49:48 -05:00
commit 39a1e893fb
150 changed files with 28157 additions and 3292 deletions

2
.gitignore vendored
View File

@ -34,7 +34,7 @@ __pycache__
/tar-build
/setup-bundle-build
/dist
*.egg-info
/*.egg-info
*.py[c,o]
# JavaScript

View File

@ -273,9 +273,9 @@ version_file:
# Do any one-time init tasks.
init:
@if [ "$(VIRTUAL_ENV)" ]; then \
awx-manage register_instance --primary --hostname=127.0.0.1; \
tower-manage register_instance --primary --hostname=127.0.0.1; \
else \
sudo awx-manage register_instance --primary --hostname=127.0.0.1; \
sudo tower-manage register_instance --primary --hostname=127.0.0.1; \
fi
# Refresh development environment after pulling new code.
@ -291,7 +291,7 @@ migrate:
# Run after making changes to the models to create a new migration.
dbchange:
$(PYTHON) manage.py schemamigration main v14_changes --auto
$(PYTHON) manage.py makemigrations
# access database shell, asks for password
dbshell:
@ -358,7 +358,7 @@ pylint: reports
@(set -o pipefail && $@ | reports/$@.report)
check: flake8 pep8 # pyflakes pylint
# Run all API unit tests.
test:
py.test awx/main/tests awx/api/tests awx/fact/tests

View File

@ -32,7 +32,8 @@ __all__ = ['APIView', 'GenericAPIView', 'ListAPIView', 'SimpleListAPIView',
'SubListCreateAttachDetachAPIView', 'RetrieveAPIView',
'RetrieveUpdateAPIView', 'RetrieveDestroyAPIView',
'RetrieveUpdateDestroyAPIView', 'DestroyAPIView',
'MongoAPIView', 'MongoListAPIView']
'SubDetailAPIView',
'ParentMixin',]
logger = logging.getLogger('awx.api.generics')
@ -219,28 +220,6 @@ class GenericAPIView(generics.GenericAPIView, APIView):
d['settings'] = settings
return d
class MongoAPIView(GenericAPIView):
def get_parent_object(self):
parent_filter = {
self.lookup_field: self.kwargs.get(self.lookup_field, None),
}
return get_object_or_404(self.parent_model, **parent_filter)
def check_parent_access(self, parent=None):
parent = parent or self.get_parent_object()
parent_access = getattr(self, 'parent_access', 'read')
if parent_access in ('read', 'delete'):
args = (self.parent_model, parent_access, parent)
else:
args = (self.parent_model, parent_access, parent, None)
if not self.request.user.can_access(*args):
raise PermissionDenied()
class MongoListAPIView(generics.ListAPIView, MongoAPIView):
pass
class SimpleListAPIView(generics.ListAPIView, GenericAPIView):
def get_queryset(self):
@ -277,7 +256,25 @@ class ListCreateAPIView(ListAPIView, generics.ListCreateAPIView):
# Base class for a list view that allows creating new objects.
pass
class SubListAPIView(ListAPIView):
class ParentMixin(object):
def get_parent_object(self):
parent_filter = {
self.lookup_field: self.kwargs.get(self.lookup_field, None),
}
return get_object_or_404(self.parent_model, **parent_filter)
def check_parent_access(self, parent=None):
parent = parent or self.get_parent_object()
parent_access = getattr(self, 'parent_access', 'read')
if parent_access in ('read', 'delete'):
args = (self.parent_model, parent_access, parent)
else:
args = (self.parent_model, parent_access, parent, None)
if not self.request.user.can_access(*args):
raise PermissionDenied()
class SubListAPIView(ListAPIView, ParentMixin):
# Base class for a read-only sublist view.
# Subclasses should define at least:
@ -297,22 +294,6 @@ class SubListAPIView(ListAPIView):
})
return d
def get_parent_object(self):
parent_filter = {
self.lookup_field: self.kwargs.get(self.lookup_field, None),
}
return get_object_or_404(self.parent_model, **parent_filter)
def check_parent_access(self, parent=None):
parent = parent or self.get_parent_object()
parent_access = getattr(self, 'parent_access', 'read')
if parent_access in ('read', 'delete'):
args = (self.parent_model, parent_access, parent)
else:
args = (self.parent_model, parent_access, parent, None)
if not self.request.user.can_access(*args):
raise PermissionDenied()
def get_queryset(self):
parent = self.get_parent_object()
self.check_parent_access(parent)
@ -449,6 +430,9 @@ class SubListCreateAttachDetachAPIView(SubListCreateAPIView):
else:
return self.attach(request, *args, **kwargs)
class SubDetailAPIView(generics.RetrieveAPIView, GenericAPIView, ParentMixin):
pass
class RetrieveAPIView(generics.RetrieveAPIView, GenericAPIView):
pass

View File

@ -13,7 +13,7 @@ from rest_framework import serializers
from rest_framework.request import clone_request
# Ansible Tower
from awx.main.models import InventorySource
from awx.main.models import InventorySource, Notifier
class Metadata(metadata.SimpleMetadata):
@ -76,6 +76,12 @@ class Metadata(metadata.SimpleMetadata):
get_group_by_choices = getattr(InventorySource, 'get_%s_group_by_choices' % cp)
field_info['%s_group_by_choices' % cp] = get_group_by_choices()
# Special handling of notification configuration where the required properties
# are conditional on the type selected.
if field.field_name == 'notification_configuration':
for (notification_type_name, notification_tr_name, notification_type_class) in Notifier.NOTIFICATION_TYPES:
field_info[notification_type_name] = notification_type_class.init_parameters
# Update type of fields returned...
if field.field_name == 'type':
field_info['type'] = 'multiple choice'

View File

@ -9,8 +9,6 @@ import logging
from collections import OrderedDict
from dateutil import rrule
from rest_framework_mongoengine.serializers import DocumentSerializer
# PyYAML
import yaml
@ -46,12 +44,10 @@ from awx.main.conf import tower_settings
from awx.api.license import feature_enabled
from awx.api.fields import BooleanNullField, CharNullField, ChoiceNullField, EncryptedPasswordField, VerbatimField
from awx.fact.models import * # noqa
logger = logging.getLogger('awx.api.serializers')
# Fields that should be summarized regardless of object type.
DEFAULT_SUMMARY_FIELDS = ('name', 'description')# , 'created_by', 'modified_by')#, 'type')
DEFAULT_SUMMARY_FIELDS = ('id', 'name', 'description')# , 'created_by', 'modified_by')#, 'type')
# Keys are fields (foreign keys) where, if found on an instance, summary info
# should be added to the serialized data. Values are a tuple of field names on
@ -362,6 +358,7 @@ class BaseSerializer(serializers.ModelSerializer):
roles[field.name] = {
'id': role.id,
'name': role.name,
'description': role.description,
'url': role.get_absolute_url(),
}
if len(roles) > 0:
@ -555,19 +552,19 @@ class BaseSerializer(serializers.ModelSerializer):
class EmptySerializer(serializers.Serializer):
pass
class BaseFactSerializer(DocumentSerializer):
class BaseFactSerializer(BaseSerializer):
__metaclass__ = BaseSerializerMetaclass
def get_fields(self):
ret = super(BaseFactSerializer, self).get_fields()
if 'module' in ret and feature_enabled('system_tracking'):
choices = [(o, o.title()) for o in FactVersion.objects.all().only('module').distinct('module')]
ret['module'] = serializers.ChoiceField(source='module', choices=choices, read_only=True, required=False)
if 'module' in ret:
# TODO: the values_list may pull in a LOT of entries before the distinct is called
modules = Fact.objects.all().values_list('module', flat=True).distinct()
choices = [(o, o.title()) for o in modules]
ret['module'] = serializers.ChoiceField(choices=choices, read_only=True, required=False)
return ret
class UnifiedJobTemplateSerializer(BaseSerializer):
class Meta:
@ -868,7 +865,11 @@ class OrganizationSerializer(BaseSerializer):
users = reverse('api:organization_users_list', args=(obj.pk,)),
admins = reverse('api:organization_admins_list', args=(obj.pk,)),
teams = reverse('api:organization_teams_list', args=(obj.pk,)),
activity_stream = reverse('api:organization_activity_stream_list', args=(obj.pk,))
activity_stream = reverse('api:organization_activity_stream_list', args=(obj.pk,)),
notifiers = reverse('api:organization_notifiers_list', args=(obj.pk,)),
notifiers_any = reverse('api:organization_notifiers_any_list', args=(obj.pk,)),
notifiers_success = reverse('api:organization_notifiers_success_list', args=(obj.pk,)),
notifiers_error = reverse('api:organization_notifiers_error_list', args=(obj.pk,)),
))
return res
@ -938,6 +939,9 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
project_updates = reverse('api:project_updates_list', args=(obj.pk,)),
schedules = reverse('api:project_schedules_list', args=(obj.pk,)),
activity_stream = reverse('api:project_activity_stream_list', args=(obj.pk,)),
notifiers_any = reverse('api:project_notifiers_any_list', args=(obj.pk,)),
notifiers_success = reverse('api:project_notifiers_success_list', args=(obj.pk,)),
notifiers_error = reverse('api:project_notifiers_error_list', args=(obj.pk,)),
))
# Backwards compatibility.
if obj.current_update:
@ -983,6 +987,7 @@ class ProjectUpdateSerializer(UnifiedJobSerializer, ProjectOptionsSerializer):
res.update(dict(
project = reverse('api:project_detail', args=(obj.project.pk,)),
cancel = reverse('api:project_update_cancel', args=(obj.pk,)),
notifications = reverse('api:project_update_notifications_list', args=(obj.pk,)),
))
return res
@ -1390,6 +1395,9 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
activity_stream = reverse('api:inventory_activity_stream_list', args=(obj.pk,)),
hosts = reverse('api:inventory_source_hosts_list', args=(obj.pk,)),
groups = reverse('api:inventory_source_groups_list', args=(obj.pk,)),
notifiers_any = reverse('api:inventory_source_notifiers_any_list', args=(obj.pk,)),
notifiers_success = reverse('api:inventory_source_notifiers_success_list', args=(obj.pk,)),
notifiers_error = reverse('api:inventory_source_notifiers_error_list', args=(obj.pk,)),
))
if obj.inventory and obj.inventory.active:
res['inventory'] = reverse('api:inventory_detail', args=(obj.inventory.pk,))
@ -1434,6 +1442,7 @@ class InventoryUpdateSerializer(UnifiedJobSerializer, InventorySourceOptionsSeri
res.update(dict(
inventory_source = reverse('api:inventory_source_detail', args=(obj.inventory_source.pk,)),
cancel = reverse('api:inventory_update_cancel', args=(obj.pk,)),
notifications = reverse('api:inventory_update_notifications_list', args=(obj.pk,)),
))
return res
@ -1532,7 +1541,7 @@ class ResourceAccessListElementSerializer(UserSerializer):
ret['summary_fields']['permissions'] = resource.get_permissions(user)
def format_role_perm(role):
role_dict = { 'id': role.id, 'name': role.name}
role_dict = { 'id': role.id, 'name': role.name, 'description': role.description}
try:
role_dict['resource_name'] = role.content_object.name
role_dict['resource_type'] = role.content_type.name
@ -1672,6 +1681,9 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer):
schedules = reverse('api:job_template_schedules_list', args=(obj.pk,)),
activity_stream = reverse('api:job_template_activity_stream_list', args=(obj.pk,)),
launch = reverse('api:job_template_launch', args=(obj.pk,)),
notifiers_any = reverse('api:job_template_notifiers_any_list', args=(obj.pk,)),
notifiers_success = reverse('api:job_template_notifiers_success_list', args=(obj.pk,)),
notifiers_error = reverse('api:job_template_notifiers_error_list', args=(obj.pk,)),
))
if obj.host_config_key:
res['callback'] = reverse('api:job_template_callback', args=(obj.pk,))
@ -1726,6 +1738,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
job_tasks = reverse('api:job_job_tasks_list', args=(obj.pk,)),
job_host_summaries = reverse('api:job_job_host_summaries_list', args=(obj.pk,)),
activity_stream = reverse('api:job_activity_stream_list', args=(obj.pk,)),
notifications = reverse('api:job_notifications_list', args=(obj.pk,)),
))
if obj.job_template and obj.job_template.active:
res['job_template'] = reverse('api:job_template_detail',
@ -2141,6 +2154,79 @@ class JobLaunchSerializer(BaseSerializer):
attrs = super(JobLaunchSerializer, self).validate(attrs)
return attrs
class NotifierSerializer(BaseSerializer):
class Meta:
model = Notifier
fields = ('*', 'organization', 'notification_type', 'notification_configuration')
type_map = {"string": (str, unicode),
"int": (int,),
"bool": (bool,),
"list": (list,),
"password": (str, unicode),
"object": (dict, OrderedDict)}
def to_representation(self, obj):
ret = super(NotifierSerializer, self).to_representation(obj)
for field in obj.notification_class.init_parameters:
if field in ret['notification_configuration'] and \
force_text(ret['notification_configuration'][field]).startswith('$encrypted$'):
ret['notification_configuration'][field] = '$encrypted$'
return ret
def get_related(self, obj):
res = super(NotifierSerializer, self).get_related(obj)
res.update(dict(
test = reverse('api:notifier_test', args=(obj.pk,)),
notifications = reverse('api:notifier_notification_list', args=(obj.pk,)),
))
if obj.organization and obj.organization.active:
res['organization'] = reverse('api:organization_detail', args=(obj.organization.pk,))
return res
def validate(self, attrs):
notification_class = Notifier.CLASS_FOR_NOTIFICATION_TYPE[attrs['notification_type']]
missing_fields = []
incorrect_type_fields = []
if 'notification_configuration' not in attrs:
return attrs
for field in notification_class.init_parameters:
if field not in attrs['notification_configuration']:
missing_fields.append(field)
continue
field_val = attrs['notification_configuration'][field]
field_type = notification_class.init_parameters[field]['type']
expected_types = self.type_map[field_type]
if not type(field_val) in expected_types:
incorrect_type_fields.append((field, field_type))
continue
if field_type == "password" and field_val.startswith('$encrypted$'):
missing_fields.append(field)
error_list = []
if missing_fields:
error_list.append("Missing required fields for Notification Configuration: {}".format(missing_fields))
if incorrect_type_fields:
for type_field_error in incorrect_type_fields:
error_list.append("Configuration field '{}' incorrect type, expected {}".format(type_field_error[0],
type_field_error[1]))
if error_list:
raise serializers.ValidationError(error_list)
return attrs
class NotificationSerializer(BaseSerializer):
class Meta:
model = Notification
fields = ('*', '-name', '-description', 'notifier', 'error', 'status', 'notifications_sent',
'notification_type', 'recipients', 'subject')
def get_related(self, obj):
res = super(NotificationSerializer, self).get_related(obj)
res.update(dict(
notifier = reverse('api:notifier_detail', args=(obj.notifier.pk,)),
))
return res
class ScheduleSerializer(BaseSerializer):
@ -2391,28 +2477,31 @@ class AuthTokenSerializer(serializers.Serializer):
class FactVersionSerializer(BaseFactSerializer):
related = serializers.SerializerMethodField('get_related')
class Meta:
model = FactVersion
fields = ('related', 'module', 'timestamp',)
model = Fact
fields = ('related', 'module', 'timestamp')
read_only_fields = ('*',)
def get_related(self, obj):
host_obj = self.context.get('host_obj')
res = {}
res = super(FactVersionSerializer, self).get_related(obj)
params = {
'datetime': timestamp_apiformat(obj.timestamp),
'module': obj.module,
}
res.update(dict(
fact_view = build_url('api:host_fact_compare_view', args=(host_obj.pk,), get=params),
))
res['fact_view'] = build_url('api:host_fact_compare_view', args=(obj.host.pk,), get=params)
return res
class FactSerializer(BaseFactSerializer):
class Meta:
model = Fact
depth = 2
fields = ('timestamp', 'host', 'module', 'fact')
# TODO: Consider adding in host to the fields list ?
fields = ('related', 'timestamp', 'module', 'facts', 'id', 'summary_fields', 'host')
read_only_fields = ('*',)
def get_related(self, obj):
res = super(FactSerializer, self).get_related(obj)
res['host'] = obj.host.get_absolute_url()
return res

View File

@ -20,6 +20,10 @@ organization_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/projects/$', 'organization_projects_list'),
url(r'^(?P<pk>[0-9]+)/teams/$', 'organization_teams_list'),
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'organization_activity_stream_list'),
url(r'^(?P<pk>[0-9]+)/notifiers/$', 'organization_notifiers_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'organization_notifiers_any_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'organization_notifiers_error_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'organization_notifiers_success_list'),
)
user_urls = patterns('awx.api.views',
@ -44,12 +48,16 @@ project_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/project_updates/$', 'project_updates_list'),
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'project_activity_stream_list'),
url(r'^(?P<pk>[0-9]+)/schedules/$', 'project_schedules_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'project_notifiers_any_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'project_notifiers_error_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'project_notifiers_success_list'),
)
project_update_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/$', 'project_update_detail'),
url(r'^(?P<pk>[0-9]+)/cancel/$', 'project_update_cancel'),
url(r'^(?P<pk>[0-9]+)/stdout/$', 'project_update_stdout'),
url(r'^(?P<pk>[0-9]+)/notifications/$', 'project_update_notifications_list'),
)
team_urls = patterns('awx.api.views',
@ -92,8 +100,8 @@ host_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/ad_hoc_commands/$', 'host_ad_hoc_commands_list'),
url(r'^(?P<pk>[0-9]+)/ad_hoc_command_events/$', 'host_ad_hoc_command_events_list'),
#url(r'^(?P<pk>[0-9]+)/single_fact/$', 'host_single_fact_view'),
url(r'^(?P<pk>[0-9]+)/fact_versions/$', 'host_fact_versions_list'),
url(r'^(?P<pk>[0-9]+)/fact_view/$', 'host_fact_compare_view'),
url(r'^(?P<pk>[0-9]+)/fact_versions/$', 'host_fact_versions_list'),
url(r'^(?P<pk>[0-9]+)/fact_view/$', 'host_fact_compare_view'),
)
group_urls = patterns('awx.api.views',
@ -121,12 +129,16 @@ inventory_source_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/schedules/$', 'inventory_source_schedules_list'),
url(r'^(?P<pk>[0-9]+)/groups/$', 'inventory_source_groups_list'),
url(r'^(?P<pk>[0-9]+)/hosts/$', 'inventory_source_hosts_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'inventory_source_notifiers_any_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'inventory_source_notifiers_error_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'inventory_source_notifiers_success_list'),
)
inventory_update_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/$', 'inventory_update_detail'),
url(r'^(?P<pk>[0-9]+)/cancel/$', 'inventory_update_cancel'),
url(r'^(?P<pk>[0-9]+)/stdout/$', 'inventory_update_stdout'),
url(r'^(?P<pk>[0-9]+)/notifications/$', 'inventory_update_notifications_list'),
)
inventory_script_urls = patterns('awx.api.views',
@ -168,6 +180,9 @@ job_template_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/schedules/$', 'job_template_schedules_list'),
url(r'^(?P<pk>[0-9]+)/survey_spec/$', 'job_template_survey_spec'),
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'job_template_activity_stream_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_any/$', 'job_template_notifiers_any_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_error/$', 'job_template_notifiers_error_list'),
url(r'^(?P<pk>[0-9]+)/notifiers_success/$', 'job_template_notifiers_success_list'),
)
job_urls = patterns('awx.api.views',
@ -182,6 +197,7 @@ job_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/job_tasks/$', 'job_job_tasks_list'),
url(r'^(?P<pk>[0-9]+)/activity_stream/$', 'job_activity_stream_list'),
url(r'^(?P<pk>[0-9]+)/stdout/$', 'job_stdout'),
url(r'^(?P<pk>[0-9]+)/notifications/$', 'job_notifications_list'),
)
job_host_summary_urls = patterns('awx.api.views',
@ -224,6 +240,18 @@ system_job_urls = patterns('awx.api.views',
url(r'^(?P<pk>[0-9]+)/cancel/$', 'system_job_cancel'),
)
notifier_urls = patterns('awx.api.views',
url(r'^$', 'notifier_list'),
url(r'^(?P<pk>[0-9]+)/$', 'notifier_detail'),
url(r'^(?P<pk>[0-9]+)/test/$', 'notifier_test'),
url(r'^(?P<pk>[0-9]+)/notifications/$', 'notifier_notification_list'),
)
notification_urls = patterns('awx.api.views',
url(r'^$', 'notification_list'),
url(r'^(?P<pk>[0-9]+)/$', 'notification_detail'),
)
schedule_urls = patterns('awx.api.views',
url(r'^$', 'schedule_list'),
url(r'^(?P<pk>[0-9]+)/$', 'schedule_detail'),
@ -273,6 +301,8 @@ v1_urls = patterns('awx.api.views',
url(r'^ad_hoc_command_events/', include(ad_hoc_command_event_urls)),
url(r'^system_job_templates/', include(system_job_template_urls)),
url(r'^system_jobs/', include(system_job_urls)),
url(r'^notifiers/', include(notifier_urls)),
url(r'^notifications/', include(notification_urls)),
url(r'^unified_job_templates/$', 'unified_job_template_list'),
url(r'^unified_jobs/$', 'unified_job_list'),
url(r'^activity_stream/', include(activity_stream_urls)),

View File

@ -42,9 +42,6 @@ from rest_framework import status
from rest_framework_yaml.parsers import YAMLParser
from rest_framework_yaml.renderers import YAMLRenderer
# MongoEngine
import mongoengine
# QSStats
import qsstats
@ -56,12 +53,11 @@ 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.tasks import mongodb_control, send_notifications
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
@ -70,7 +66,6 @@ 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
@ -137,6 +132,8 @@ class ApiV1RootView(APIView):
data['schedules'] = reverse('api:schedule_list')
data['roles'] = reverse('api:role_list')
data['resources'] = reverse('api:resource_list')
data['notifiers'] = reverse('api:notifier_list')
data['notifications'] = reverse('api:notification_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')
@ -252,32 +249,12 @@ class ApiV1ConfigView(APIView):
# 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.
tower_settings.TOWER_URL_BASE = "{}://{}".format(request.scheme, request.get_host())
return Response(license_data)
return Response({"error": "Invalid license"}, status=status.HTTP_400_BAD_REQUEST)
def delete(self, request):
@ -698,6 +675,35 @@ class OrganizationActivityStreamList(SubListAPIView):
# Okay, let it through.
return super(type(self), self).get(request, *args, **kwargs)
class OrganizationNotifiersList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = Organization
relationship = 'notifiers'
parent_key = 'organization'
class OrganizationNotifiersAnyList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = Organization
relationship = 'notifiers_any'
class OrganizationNotifiersErrorList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = Organization
relationship = 'notifiers_error'
class OrganizationNotifiersSuccessList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = Organization
relationship = 'notifiers_success'
class TeamList(ListCreateAPIView):
model = Team
@ -724,11 +730,8 @@ class TeamRolesList(SubListCreateAttachDetachAPIView):
relationship='member_role.children'
def get_queryset(self):
# XXX: This needs to be the intersection between
# what roles the user has and what roles the viewer
# has access to see.
team = Team.objects.get(pk=self.kwargs['pk'])
return team.member_role.children
return team.member_role.children.filter(id__in=Role.visible_roles(self.request.user))
# XXX: Need to enforce permissions
def post(self, request, *args, **kwargs):
@ -868,6 +871,26 @@ class ProjectActivityStreamList(SubListAPIView):
return qs.filter(project=parent)
return qs.filter(Q(project=parent) | Q(credential__in=parent.credential))
class ProjectNotifiersAnyList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = Project
relationship = 'notifiers_any'
class ProjectNotifiersErrorList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = Project
relationship = 'notifiers_error'
class ProjectNotifiersSuccessList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = Project
relationship = 'notifiers_success'
class ProjectUpdatesList(SubListAPIView):
@ -918,6 +941,12 @@ class ProjectUpdateCancel(RetrieveAPIView):
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class ProjectUpdateNotificationsList(SubListAPIView):
model = Notification
serializer_class = NotificationSerializer
parent_model = Project
relationship = 'notifications'
class UserList(ListCreateAPIView):
@ -947,13 +976,11 @@ class UserRolesList(SubListCreateAttachDetachAPIView):
serializer_class = RoleSerializer
parent_model = User
relationship='roles'
permission_classes = (IsAuthenticated,)
def get_queryset(self):
# XXX: This needs to be the intersection between
# what roles the user has and what roles the viewer
# has access to see.
u = User.objects.get(pk=self.kwargs['pk'])
return u.roles
#u = User.objects.get(pk=self.kwargs['pk'])
return Role.visible_roles(self.request.user).filter(members__in=[int(self.kwargs['pk']), ])
def post(self, request, *args, **kwargs):
# Forbid implicit role creation here
@ -963,6 +990,10 @@ class UserRolesList(SubListCreateAttachDetachAPIView):
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(type(self), self).post(request, *args, **kwargs)
def check_parent_access(self, parent=None):
# We hide roles that shouldn't be seen in our queryset
return True
class UserProjectsList(SubListAPIView):
@ -1172,33 +1203,6 @@ class InventoryScanJobTemplateList(SubListAPIView):
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
@ -1285,102 +1289,59 @@ class HostActivityStreamList(SubListAPIView):
qs = self.request.user.get_queryset(self.model)
return qs.filter(Q(host=parent) | Q(inventory=parent.inventory))
class HostFactVersionsList(MongoListAPIView):
class SystemTrackingEnforcementMixin(APIView):
'''
Use check_permissions instead of initial() because it's in the OPTION's path as well
'''
def check_permissions(self, request):
if not feature_enabled("system_tracking"):
raise LicenseForbids("Your license does not permit use "
"of system tracking.")
return super(SystemTrackingEnforcementMixin, self).check_permissions(request)
class HostFactVersionsList(ListAPIView, ParentMixin, SystemTrackingEnforcementMixin):
model = Fact
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.")
if from_spec:
from_spec = dateutil.parser.parse(from_spec)
if to_spec:
to_spec = dateutil.parser.parse(to_spec)
host = self.get_parent_object()
self.check_parent_access(host)
host_obj = self.get_parent_object()
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")
return Fact.get_timeline(host_obj.id, module=module_spec, ts_from=from_spec, ts_to=to_spec)
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))
return Response(dict(results=self.serializer_class(queryset, many=True).data))
class HostSingleFactView(MongoAPIView):
class HostFactCompareView(SubDetailAPIView, SystemTrackingEnforcementMixin):
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.')
def retrieve(self, request, *args, **kwargs):
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)
fact_entry = Fact.get_host_fact(host_obj.id, module_spec, datetime_actual)
if not fact_entry:
return Response({'detail': 'Fact not found'}, status=status.HTTP_404_NOT_FOUND)
return Response(self.serializer_class(instance=fact_entry).data)
class GroupList(ListCreateAPIView):
@ -1549,33 +1510,6 @@ class GroupDetail(RetrieveUpdateDestroyAPIView):
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
@ -1803,6 +1737,27 @@ class InventorySourceActivityStreamList(SubListAPIView):
# Okay, let it through.
return super(type(self), self).get(request, *args, **kwargs)
class InventorySourceNotifiersAnyList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = InventorySource
relationship = 'notifiers_any'
class InventorySourceNotifiersErrorList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = InventorySource
relationship = 'notifiers_error'
class InventorySourceNotifiersSuccessList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = InventorySource
relationship = 'notifiers_success'
class InventorySourceHostsList(SubListAPIView):
model = Host
@ -1867,6 +1822,13 @@ class InventoryUpdateCancel(RetrieveAPIView):
else:
return self.http_method_not_allowed(request, *args, **kwargs)
class InventoryUpdateNotificationsList(SubListAPIView):
model = Notification
serializer_class = NotificationSerializer
parent_model = InventoryUpdate
relationship = 'notifications'
class JobTemplateList(ListCreateAPIView):
model = JobTemplate
@ -2036,6 +1998,27 @@ class JobTemplateActivityStreamList(SubListAPIView):
# Okay, let it through.
return super(type(self), self).get(request, *args, **kwargs)
class JobTemplateNotifiersAnyList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = JobTemplate
relationship = 'notifiers_any'
class JobTemplateNotifiersErrorList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = JobTemplate
relationship = 'notifiers_error'
class JobTemplateNotifiersSuccessList(SubListCreateAttachDetachAPIView):
model = Notifier
serializer_class = NotifierSerializer
parent_model = JobTemplate
relationship = 'notifiers_success'
class JobTemplateCallback(GenericAPIView):
model = JobTemplate
@ -2369,6 +2352,13 @@ class JobRelaunch(RetrieveAPIView, GenericAPIView):
headers = {'Location': new_job.get_absolute_url()}
return Response(data, status=status.HTTP_201_CREATED, headers=headers)
class JobNotificationsList(SubListAPIView):
model = Notification
serializer_class = NotificationSerializer
parent_model = Job
relationship = 'notifications'
class BaseJobHostSummariesList(SubListAPIView):
model = JobHostSummary
@ -3022,6 +3012,58 @@ class AdHocCommandStdout(UnifiedJobStdout):
model = AdHocCommand
new_in_220 = True
class NotifierList(ListCreateAPIView):
model = Notifier
serializer_class = NotifierSerializer
new_in_300 = True
class NotifierDetail(RetrieveUpdateDestroyAPIView):
model = Notifier
serializer_class = NotifierSerializer
new_in_300 = True
class NotifierTest(GenericAPIView):
view_name = 'Notifier Test'
model = Notifier
serializer_class = EmptySerializer
new_in_300 = True
def post(self, request, *args, **kwargs):
obj = self.get_object()
notification = obj.generate_notification("Tower Notification Test {} {}".format(obj.id, tower_settings.TOWER_URL_BASE),
{"body": "Ansible Tower Test Notification {} {}".format(obj.id, tower_settings.TOWER_URL_BASE)})
if not notification:
return Response({}, status=status.HTTP_400_BAD_REQUEST)
else:
send_notifications.delay([notification.id])
headers = {'Location': notification.get_absolute_url()}
return Response({"notification": notification.id},
headers=headers,
status=status.HTTP_202_ACCEPTED)
class NotifierNotificationList(SubListAPIView):
model = Notification
serializer_class = NotificationSerializer
parent_model = Notifier
relationship = 'notifications'
parent_key = 'notifier'
class NotificationList(ListAPIView):
model = Notification
serializer_class = NotificationSerializer
new_in_300 = True
class NotificationDetail(RetrieveAPIView):
model = Notification
serializer_class = NotificationSerializer
new_in_300 = True
class ActivityStreamList(SimpleListAPIView):
model = ActivityStream
@ -3120,29 +3162,27 @@ class SettingsReset(APIView):
TowerSettings.objects.filter(key=settings_key).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
#class RoleList(ListCreateAPIView):
class RoleList(ListAPIView):
model = Role
serializer_class = RoleSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True
# XXX: Permissions - only roles the user has access to see should be listed here
def get_queryset(self):
return Role.objects
if self.request.user.is_superuser:
return Role.objects
return Role.visible_roles(self.request.user)
# XXX: Need to define who can create custom roles, and then restrict access
# appropriately
# XXX: Need to define how we want to deal with administration of custom roles.
class RoleDetail(RetrieveUpdateAPIView):
class RoleDetail(RetrieveAPIView):
model = Role
serializer_class = RoleSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True
# XXX: Permissions - only appropriate people should be able to change these
class RoleUsersList(SubListCreateAttachDetachAPIView):
@ -3150,6 +3190,8 @@ class RoleUsersList(SubListCreateAttachDetachAPIView):
serializer_class = UserSerializer
parent_model = Role
relationship = 'members'
permission_classes = (IsAuthenticated,)
new_in_300 = True
def get_queryset(self):
# XXX: Access control
@ -3171,6 +3213,8 @@ class RoleTeamsList(ListAPIView):
serializer_class = TeamSerializer
parent_model = Role
relationship = 'member_role.parents'
permission_classes = (IsAuthenticated,)
new_in_300 = True
def get_queryset(self):
# TODO: Check
@ -3201,6 +3245,8 @@ class RoleParentsList(SubListAPIView):
serializer_class = RoleSerializer
parent_model = Role
relationship = 'parents'
permission_classes = (IsAuthenticated,)
new_in_300 = True
def get_queryset(self):
# XXX: This should be the intersection between the roles of the user
@ -3214,6 +3260,8 @@ class RoleChildrenList(SubListAPIView):
serializer_class = RoleSerializer
parent_model = Role
relationship = 'children'
permission_classes = (IsAuthenticated,)
new_in_300 = True
def get_queryset(self):
# XXX: This should be the intersection between the roles of the user
@ -3225,6 +3273,7 @@ class ResourceDetail(RetrieveAPIView):
model = Resource
serializer_class = ResourceSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True
# XXX: Permissions - only roles the user has access to see should be listed here
@ -3235,6 +3284,7 @@ class ResourceList(ListAPIView):
model = Resource
serializer_class = ResourceSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True
def get_queryset(self):
@ -3244,6 +3294,7 @@ class ResourceAccessList(ListAPIView):
model = User
serializer_class = ResourceAccessListElementSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True
def get_queryset(self):

View File

@ -1,28 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
from django.conf import settings
from mongoengine import connect
from mongoengine.connection import ConnectionError
from pymongo.errors import AutoReconnect
def test_mongo_connection():
# Connect to Mongo
try:
# Sanity check: If we have intentionally invalid settings, then we
# know we cannot connect.
if settings.MONGO_HOST == NotImplemented:
raise ConnectionError
# Attempt to connect to the MongoDB database.
db = connect(settings.MONGO_DB,
host=settings.MONGO_HOST,
port=int(settings.MONGO_PORT),
username=settings.MONGO_USERNAME,
password=settings.MONGO_PASSWORD,
tz_aware=settings.USE_TZ)
db[settings.MONGO_DB].command('ping')
return True
except (ConnectionError, AutoReconnect):
return False

View File

@ -1291,6 +1291,31 @@ class ScheduleAccess(BaseAccess):
else:
return False
class NotifierAccess(BaseAccess):
'''
I can see/use a notifier if I have permission to
'''
model = Notifier
def get_queryset(self):
qs = self.model.objects.filter(active=True).distinct()
if self.user.is_superuser:
return qs
return qs
class NotificationAccess(BaseAccess):
'''
I can see/use a notification if I have permission to
'''
model = Notification
def get_queryset(self):
qs = self.model.objects.distinct()
if self.user.is_superuser:
return qs
return qs
class ActivityStreamAccess(BaseAccess):
'''
I can see activity stream events only when I have permission on all objects included in the event
@ -1475,23 +1500,31 @@ class RoleAccess(BaseAccess):
def get_queryset(self):
if self.user.is_superuser:
return self.model.objects.all()
return self.model.objects.none()
return self.model.visible_roles(self.user)
def can_change(self, obj, data):
return self.user.is_superuser
def can_add(self, obj, data):
return self.user.is_superuser
# Unsupported for now
return False
def can_attach(self, obj, sub_obj, relationship, data,
skip_sub_obj_read_check=False):
return self.user.is_superuser
return self.can_unattach(obj, sub_obj, relationship)
def can_unattach(self, obj, sub_obj, relationship):
return self.user.is_superuser
if self.user.is_superuser:
return True
if obj.object_id and \
isinstance(obj.content_object, ResourceMixin) and \
obj.content_object.accessible_by(self.user, {'write': True}):
return True
return False
def can_delete(self, obj):
return self.user.is_superuser
# Unsupported for now
return False
class ResourceAccess(BaseAccess):
@ -1550,3 +1583,5 @@ register_access(CustomInventoryScript, CustomInventoryScriptAccess)
register_access(TowerSettings, TowerSettingsAccess)
register_access(Role, RoleAccess)
register_access(Resource, ResourceAccess)
register_access(Notifier, NotifierAccess)
register_access(Notification, NotificationAccess)

View File

@ -134,8 +134,9 @@ def resolve_role_field(obj, field):
class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor):
"""Descriptor Implict Role Fields. Auto-creates the appropriate role entry on first access"""
def __init__(self, role_name, permissions, parent_role, *args, **kwargs):
def __init__(self, role_name, role_description, permissions, parent_role, *args, **kwargs):
self.role_name = role_name
self.role_description = role_description if role_description else ""
self.permissions = permissions
self.parent_role = parent_role
@ -152,7 +153,7 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor):
if connection.needs_rollback:
raise TransactionManagementError('Current transaction has failed, cannot create implicit role')
role = Role.objects.create(name=self.role_name, content_object=instance)
role = Role.objects.create(name=self.role_name, description=self.role_description, content_object=instance)
if self.parent_role:
# Add all non-null parent roles as parents
@ -195,8 +196,9 @@ class ImplicitRoleDescriptor(ReverseSingleRelatedObjectDescriptor):
class ImplicitRoleField(models.ForeignKey):
"""Implicitly creates a role entry for a resource"""
def __init__(self, role_name=None, permissions=None, parent_role=None, *args, **kwargs):
def __init__(self, role_name=None, role_description=None, permissions=None, parent_role=None, *args, **kwargs):
self.role_name = role_name
self.role_description = role_description
self.permissions = permissions
self.parent_role = parent_role
@ -211,6 +213,7 @@ class ImplicitRoleField(models.ForeignKey):
self.name,
ImplicitRoleDescriptor(
self.role_name,
self.role_description,
self.permissions,
self.parent_role,
self

View File

@ -12,7 +12,7 @@ from django.db import transaction
from django.utils.timezone import now
# AWX
from awx.fact.models.fact import * # noqa
from awx.main.models.fact import Fact
from awx.api.license import feature_enabled
OLDER_THAN = 'older_than'
@ -31,7 +31,7 @@ class CleanupFacts(object):
# pivot -= granularity
# group by host
def cleanup(self, older_than_abs, granularity, module=None):
fact_oldest = FactVersion.objects.all().order_by('timestamp').first()
fact_oldest = Fact.objects.all().order_by('timestamp').first()
if not fact_oldest:
return 0
@ -44,7 +44,10 @@ class CleanupFacts(object):
# Special case, granularity=0x where x is d, w, or y
# The intent is to delete all facts < older_than_abs
if granularity == relativedelta():
return FactVersion.objects.filter(**kv).order_by('-timestamp').delete()
qs = Fact.objects.filter(**kv)
count = qs.count()
qs.delete()
return count
total = 0
@ -61,18 +64,17 @@ class CleanupFacts(object):
kv['module'] = module
fact_version_objs = FactVersion.objects.filter(**kv).order_by('-timestamp').limit(1)
if fact_version_objs:
fact_version_obj = fact_version_objs[0]
fact_version_obj = Fact.objects.filter(**kv).order_by('-timestamp').first()
if fact_version_obj:
kv = {
'timestamp__lt': fact_version_obj.timestamp,
'timestamp__gt': date_pivot_next
}
if module:
kv['module'] = module
count = FactVersion.objects.filter(**kv).delete()
# FIXME: These two deletes should be a transaction
count = Fact.objects.filter(**kv).delete()
qs = Fact.objects.filter(**kv)
count = qs.count()
qs.delete()
total += count
date_pivot = date_pivot_next

View File

@ -9,9 +9,11 @@ from datetime import datetime
# Django
from django.core.management.base import NoArgsCommand
from django.conf import settings
#from django.core.exceptions import Does
# AWX
from awx.fact.models.fact import * # noqa
from awx.main.models.fact import Fact
from awx.main.models.inventory import Host
from awx.main.socket import Socket
logger = logging.getLogger('awx.main.commands.run_fact_cache_receiver')
@ -47,35 +49,34 @@ class FactCacheReceiver(object):
# ansible v2 will not emit this message. Thus, this can be removed at that time.
if 'module_setup' in facts_data and len(facts_data) == 1:
logger.info('Received module_setup message')
return
return None
try:
host = FactHost.objects.get(hostname=hostname, inventory_id=inventory_id)
except FactHost.DoesNotExist:
logger.info('Creating new host <hostname, inventory_id> <%s, %s>' % (hostname, inventory_id))
host = FactHost(hostname=hostname, inventory_id=inventory_id)
host.save()
logger.info('Created new host <%s>' % (host.id))
except FactHost.MultipleObjectsReturned:
query = "db['fact_host'].find(hostname=%s, inventory_id=%s)" % (hostname, inventory_id)
logger.warn('Database inconsistent. Multiple FactHost "%s" exist. Try the query %s to find the records.' % (hostname, query))
host_obj = Host.objects.get(name=hostname, inventory__id=inventory_id)
except Fact.DoesNotExist:
logger.warn('Failed to intake fact. Host does not exist <hostname, inventory_id> <%s, %s>' % (hostname, inventory_id))
return
except Fact.MultipleObjectsReturned:
logger.warn('Database inconsistent. Multiple Hosts found for <hostname, inventory_id> <%s, %s>.' % (hostname, inventory_id))
return None
except Exception, e:
logger.error("Exception communicating with Fact Cache Database: %s" % str(e))
return
return None
(module, facts) = self.process_facts(facts_data)
(module_name, facts) = self.process_facts(facts_data)
self.timestamp = datetime.fromtimestamp(date_key, None)
try:
# Update existing Fact entry
version_obj = FactVersion.objects.get(timestamp=self.timestamp, host=host, module=module)
Fact.objects(id=version_obj.fact.id).update_one(fact=facts)
logger.info('Updated existing fact <%s>' % (version_obj.fact.id))
except FactVersion.DoesNotExist:
# Update existing Fact entry
fact_obj = Fact.objects.filter(host__id=host_obj.id, module=module_name, timestamp=self.timestamp)
if fact_obj:
fact_obj.facts = facts
fact_obj.save()
logger.info('Updated existing fact <%s>' % (fact_obj.id))
else:
# Create new Fact entry
(fact_obj, version_obj) = Fact.add_fact(self.timestamp, facts, host, module)
logger.info('Created new fact <fact, fact_version> <%s, %s>' % (fact_obj.id, version_obj.id))
fact_obj = Fact.add_fact(host_obj.id, module_name, self.timestamp, facts)
logger.info('Created new fact <fact_id, module> <%s, %s>' % (fact_obj.id, module_name))
return fact_obj
def run_receiver(self, use_processing_threads=True):
with Socket('fact_cache', 'r') as facts:

View File

@ -15,7 +15,7 @@ from django.core.management.base import NoArgsCommand
# AWX
from awx.main.models import * # noqa
from awx.main.queue import FifoQueue
from awx.main.tasks import handle_work_error
from awx.main.tasks import handle_work_error, handle_work_success
from awx.main.utils import get_system_task_capacity
# Celery
@ -265,14 +265,15 @@ def process_graph(graph, task_capacity):
[{'type': graph.get_node_type(n['node_object']),
'id': n['node_object'].id} for n in node_dependencies]
error_handler = handle_work_error.s(subtasks=dependent_nodes)
start_status = node_obj.start(error_callback=error_handler)
success_handler = handle_work_success.s(task_actual={'type': graph.get_node_type(node_obj),
'id': node_obj.id})
start_status = node_obj.start(error_callback=error_handler, success_callback=success_handler)
if not start_status:
node_obj.status = 'failed'
if node_obj.job_explanation:
node_obj.job_explanation += ' '
node_obj.job_explanation += 'Task failed pre-start check.'
node_obj.save()
# TODO: Run error handler
continue
remaining_volume -= impact
running_impact += impact

View File

@ -43,7 +43,7 @@ class Migration(migrations.Migration):
('created', models.DateTimeField(default=None, editable=False)),
('modified', models.DateTimeField(default=None, editable=False)),
('host_name', models.CharField(default=b'', max_length=1024, editable=False)),
('event', models.CharField(max_length=100, choices=[(b'runner_on_failed', 'Host Failed'), (b'runner_on_ok', 'Host OK'), (b'runner_on_unreachable', 'Host Unreachable')])),
('event', models.CharField(max_length=100, choices=[(b'runner_on_failed', 'Host Failed'), (b'runner_on_ok', 'Host OK'), (b'runner_on_unreachable', 'Host Unreachable'), (b'runner_on_skipped', 'Host Skipped')])),
('event_data', jsonfield.fields.JSONField(default={}, blank=True)),
('failed', models.BooleanField(default=False, editable=False)),
('changed', models.BooleanField(default=False, editable=False)),

View File

@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import jsonfield.fields
import django.db.models.deletion
from django.conf import settings
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('taggit', '0002_auto_20150616_2121'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0002_v300_changes'),
]
operations = [
migrations.CreateModel(
name='Notification',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', models.DateTimeField(default=None, editable=False)),
('modified', models.DateTimeField(default=None, editable=False)),
('status', models.CharField(default=b'pending', max_length=20, editable=False, choices=[(b'pending', 'Pending'), (b'successful', 'Successful'), (b'failed', 'Failed')])),
('error', models.TextField(default=b'', editable=False, blank=True)),
('notifications_sent', models.IntegerField(default=0, editable=False)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'irc', 'IRC')])),
('recipients', models.TextField(default=b'', editable=False, blank=True)),
('subject', models.TextField(default=b'', editable=False, blank=True)),
('body', jsonfield.fields.JSONField(default=dict, blank=True)),
],
options={
'ordering': ('pk',),
},
),
migrations.CreateModel(
name='Notifier',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('created', models.DateTimeField(default=None, editable=False)),
('modified', models.DateTimeField(default=None, editable=False)),
('description', models.TextField(default=b'', blank=True)),
('active', models.BooleanField(default=True, editable=False)),
('name', models.CharField(unique=True, max_length=512)),
('notification_type', models.CharField(max_length=32, choices=[(b'email', 'Email'), (b'slack', 'Slack'), (b'twilio', 'Twilio'), (b'pagerduty', 'Pagerduty'), (b'hipchat', 'HipChat'), (b'webhook', 'Webhook'), (b'irc', 'IRC')])),
('notification_configuration', jsonfield.fields.JSONField(default=dict)),
('created_by', models.ForeignKey(related_name="{u'class': 'notifier', u'app_label': 'main'}(class)s_created+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),
('modified_by', models.ForeignKey(related_name="{u'class': 'notifier', u'app_label': 'main'}(class)s_modified+", on_delete=django.db.models.deletion.SET_NULL, default=None, editable=False, to=settings.AUTH_USER_MODEL, null=True)),
('organization', models.ForeignKey(related_name='notifiers', on_delete=django.db.models.deletion.SET_NULL, to='main.Organization', null=True)),
('tags', taggit.managers.TaggableManager(to='taggit.Tag', through='taggit.TaggedItem', blank=True, help_text='A comma-separated list of tags.', verbose_name='Tags')),
],
),
migrations.AddField(
model_name='notification',
name='notifier',
field=models.ForeignKey(related_name='notifications', editable=False, to='main.Notifier'),
),
migrations.AddField(
model_name='activitystream',
name='notification',
field=models.ManyToManyField(to='main.Notification', blank=True),
),
migrations.AddField(
model_name='activitystream',
name='notifier',
field=models.ManyToManyField(to='main.Notifier', blank=True),
),
migrations.AddField(
model_name='organization',
name='notifiers_any',
field=models.ManyToManyField(related_name='organization_notifiers_for_any', to='main.Notifier', blank=True),
),
migrations.AddField(
model_name='organization',
name='notifiers_error',
field=models.ManyToManyField(related_name='organization_notifiers_for_errors', to='main.Notifier', blank=True),
),
migrations.AddField(
model_name='organization',
name='notifiers_success',
field=models.ManyToManyField(related_name='organization_notifiers_for_success', to='main.Notifier', blank=True),
),
migrations.AddField(
model_name='unifiedjob',
name='notifications',
field=models.ManyToManyField(related_name='unifiedjob_notifications', editable=False, to='main.Notification'),
),
migrations.AddField(
model_name='unifiedjobtemplate',
name='notifiers_any',
field=models.ManyToManyField(related_name='unifiedjobtemplate_notifiers_for_any', to='main.Notifier', blank=True),
),
migrations.AddField(
model_name='unifiedjobtemplate',
name='notifiers_error',
field=models.ManyToManyField(related_name='unifiedjobtemplate_notifiers_for_errors', to='main.Notifier', blank=True),
),
migrations.AddField(
model_name='unifiedjobtemplate',
name='notifiers_success',
field=models.ManyToManyField(related_name='unifiedjobtemplate_notifiers_for_success', to='main.Notifier', blank=True),
),
]

View File

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import jsonbfield.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0003_v300_changes'),
]
operations = [
migrations.CreateModel(
name='Fact',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('timestamp', models.DateTimeField(default=None, help_text='Date and time of the corresponding fact scan gathering time.', editable=False)),
('module', models.CharField(max_length=128)),
('facts', jsonbfield.fields.JSONField(default={}, help_text='Arbitrary JSON structure of module facts captured at timestamp for a single host.', blank=True)),
('host', models.ForeignKey(related_name='facts', to='main.Host', help_text='Host for the facts that the fact scan captured.')),
],
),
migrations.AlterIndexTogether(
name='fact',
index_together=set([('timestamp', 'module', 'host')]),
),
]

View File

@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from awx.main.migrations import _rbac as rbac
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0004_v300_changes'),
]
operations = [
# This is a placeholder for our future active flag removal work
]

View File

@ -14,7 +14,7 @@ class Migration(migrations.Migration):
('taggit', '0002_auto_20150616_2121'),
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('main', '0002_v300_changes'),
('main', '0005_v300_active_field_changes'),
]
operations = [

View File

@ -8,7 +8,7 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0003_rbac_changes'),
('main', '0006_v300_rbac_changes'),
]
operations = [

View File

@ -20,6 +20,8 @@ from awx.main.models.configuration import * # noqa
from awx.main.models.rbac import * # noqa
from awx.main.models.user import * # noqa
from awx.main.models.mixins import * # noqa
from awx.main.models.notifications import * # noqa
from awx.main.models.fact import * # noqa
# Monkeypatch Django serializer to ignore django-taggit fields (which break
# the dumpdata command; see https://github.com/alex/django-taggit/issues/155).
@ -62,3 +64,5 @@ activity_stream_registrar.connect(AdHocCommand)
activity_stream_registrar.connect(Schedule)
activity_stream_registrar.connect(CustomInventoryScript)
activity_stream_registrar.connect(TowerSettings)
activity_stream_registrar.connect(Notifier)
activity_stream_registrar.connect(Notification)

View File

@ -53,6 +53,8 @@ class ActivityStream(models.Model):
ad_hoc_command = models.ManyToManyField("AdHocCommand", blank=True)
schedule = models.ManyToManyField("Schedule", blank=True)
custom_inventory_script = models.ManyToManyField("CustomInventoryScript", blank=True)
notifier = models.ManyToManyField("Notifier", blank=True)
notification = models.ManyToManyField("Notification", blank=True)
def get_absolute_url(self):
return reverse('api:activity_stream_detail', args=(self.pk,))

View File

@ -5,6 +5,7 @@
import hmac
import json
import logging
from urlparse import urljoin
# Django
from django.conf import settings
@ -139,6 +140,9 @@ class AdHocCommand(UnifiedJob):
def get_absolute_url(self):
return reverse('api:ad_hoc_command_detail', args=(self.pk,))
def get_ui_url(self):
return urljoin(tower_settings.TOWER_URL_BASE, "/#/ad_hoc_commands/{}".format(self.pk))
@property
def task_auth_token(self):
'''Return temporary auth token used for task requests via API.'''
@ -221,8 +225,9 @@ class AdHocCommandEvent(CreatedModifiedModel):
('runner_on_unreachable', _('Host Unreachable'), True),
# Tower won't see no_hosts (check is done earlier without callback).
#('runner_on_no_hosts', _('No Hosts Matched'), False),
# Tower should probably never see skipped (no conditionals).
#('runner_on_skipped', _('Host Skipped'), False),
# Tower will see skipped (when running in check mode for a module that
# does not support check mode).
('runner_on_skipped', _('Host Skipped'), False),
# Tower does not support async for ad hoc commands.
#('runner_on_async_poll', _('Host Polling'), False),
#('runner_on_async_ok', _('Host Async OK'), False),

View File

@ -25,7 +25,7 @@ from awx.main.utils import encrypt_field
__all__ = ['VarsDictProperty', 'BaseModel', 'CreatedModifiedModel',
'PasswordFieldsModel', 'PrimordialModel', 'CommonModel',
'CommonModelNameNotUnique',
'CommonModelNameNotUnique', 'NotificationFieldsModel',
'PERM_INVENTORY_ADMIN', 'PERM_INVENTORY_READ',
'PERM_INVENTORY_WRITE', 'PERM_INVENTORY_DEPLOY', 'PERM_INVENTORY_SCAN',
'PERM_INVENTORY_CHECK', 'PERM_JOBTEMPLATE_CREATE', 'JOB_TYPE_CHOICES',
@ -337,3 +337,26 @@ class CommonModelNameNotUnique(PrimordialModel):
max_length=512,
unique=False,
)
class NotificationFieldsModel(BaseModel):
class Meta:
abstract = True
notifiers_error = models.ManyToManyField(
"Notifier",
blank=True,
related_name='%(class)s_notifiers_for_errors'
)
notifiers_success = models.ManyToManyField(
"Notifier",
blank=True,
related_name='%(class)s_notifiers_for_success'
)
notifiers_any = models.ManyToManyField(
"Notifier",
blank=True,
related_name='%(class)s_notifiers_for_any'
)

View File

@ -157,11 +157,13 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
)
owner_role = ImplicitRoleField(
role_name='Credential Owner',
role_description='Owner of the credential',
parent_role='team.admin_role',
permissions = {'all': True}
)
usage_role = ImplicitRoleField(
role_name='Credential User',
role_description='May use this credential, but not read sensitive portions or modify it',
parent_role= 'team.member_role',
permissions = {'use': True}
)

64
awx/main/models/fact.py Normal file
View File

@ -0,0 +1,64 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
from django.db import models
from django.utils.translation import ugettext_lazy as _
from jsonbfield.fields import JSONField
__all__ = ('Fact', )
class Fact(models.Model):
"""A model representing a fact returned from Ansible.
Facts are stored as JSON dictionaries.
"""
host = models.ForeignKey(
'Host',
related_name='facts',
db_index=True,
on_delete=models.CASCADE,
help_text=_('Host for the facts that the fact scan captured.'),
)
timestamp = models.DateTimeField(
default=None,
editable=False,
help_text=_('Date and time of the corresponding fact scan gathering time.')
)
module = models.CharField(max_length=128)
facts = JSONField(blank=True, default={}, help_text=_('Arbitrary JSON structure of module facts captured at timestamp for a single host.'))
class Meta:
app_label = 'main'
index_together = [
["timestamp", "module", "host"],
]
@staticmethod
def get_host_fact(host_id, module, timestamp):
qs = Fact.objects.filter(host__id=host_id, module=module, timestamp__lte=timestamp).order_by('-timestamp')
if qs:
return qs[0]
else:
return None
@staticmethod
def get_timeline(host_id, module=None, ts_from=None, ts_to=None):
kwargs = {
'host__id': host_id,
}
if module:
kwargs['module'] = module
if ts_from and ts_to and ts_from == ts_to:
kwargs['timestamp'] = ts_from
else:
if ts_from:
kwargs['timestamp__gt'] = ts_from
if ts_to:
kwargs['timestamp__lte'] = ts_to
return Fact.objects.filter(**kwargs).order_by('-timestamp').only('timestamp', 'module').order_by('-timestamp', 'module')
@staticmethod
def add_fact(host_id, module, timestamp, facts):
fact_obj = Fact.objects.create(host_id=host_id, module=module, timestamp=timestamp, facts=facts)
fact_obj.save()
return fact_obj

View File

@ -6,6 +6,7 @@ import datetime
import logging
import re
import copy
from urlparse import urljoin
# Django
from django.conf import settings
@ -24,7 +25,9 @@ from awx.main.models.base import * # noqa
from awx.main.models.jobs import Job
from awx.main.models.unified_jobs import * # noqa
from awx.main.models.mixins import ResourceMixin
from awx.main.models.notifications import Notifier
from awx.main.utils import ignore_inventory_computed_fields, _inventory_updates
from awx.main.conf import tower_settings
__all__ = ['Inventory', 'Host', 'Group', 'InventorySource', 'InventoryUpdate', 'CustomInventoryScript']
@ -95,19 +98,23 @@ class Inventory(CommonModel, ResourceMixin):
)
admin_role = ImplicitRoleField(
role_name='Inventory Administrator',
role_description='May manage this inventory',
parent_role='organization.admin_role',
permissions = {'all': True}
)
auditor_role = ImplicitRoleField(
role_name='Inventory Auditor',
role_description='May view but not modify this inventory',
parent_role='organization.auditor_role',
permissions = {'read': True}
)
updater_role = ImplicitRoleField(
role_name='Inventory Updater',
role_description='May update the inventory',
)
executor_role = ImplicitRoleField(
role_name='Inventory Executor',
role_description='May execute jobs against this inventory',
)
def get_absolute_url(self):
@ -1217,6 +1224,14 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, ResourceMixin)
return True
return False
@property
def notifiers(self):
base_notifiers = Notifier.objects.filter(active=True)
error_notifiers = list(base_notifiers.filter(organization_notifiers_for_errors=self.inventory.organization))
success_notifiers = list(base_notifiers.filter(organization_notifiers_for_success=self.inventory.organization))
any_notifiers = list(base_notifiers.filter(organization_notifiers_for_any=self.inventory.organization))
return dict(error=error_notifiers, success=success_notifiers, any=any_notifiers)
def clean_source(self):
source = self.source
if source and self.group:
@ -1276,6 +1291,9 @@ class InventoryUpdate(UnifiedJob, InventorySourceOptions):
def get_absolute_url(self):
return reverse('api:inventory_update_detail', args=(self.pk,))
def get_ui_url(self):
return urljoin(tower_settings.TOWER_URL_BASE, "/#/inventory_sync/{}".format(self.pk))
def is_blocked_by(self, obj):
if type(obj) == InventoryUpdate:
if self.inventory_source.inventory == obj.inventory_source.inventory:

View File

@ -6,6 +6,7 @@ import hmac
import json
import yaml
import logging
from urlparse import urljoin
# Django
from django.conf import settings
@ -22,6 +23,7 @@ from jsonfield import JSONField
from awx.main.constants import CLOUD_PROVIDERS
from awx.main.models.base import * # noqa
from awx.main.models.unified_jobs import * # noqa
from awx.main.models.notifications import Notifier
from awx.main.utils import decrypt_field, ignore_inventory_computed_fields
from awx.main.utils import emit_websocket_notification
from awx.main.redact import PlainTextCleaner
@ -183,16 +185,19 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
)
admin_role = ImplicitRoleField(
role_name='Job Template Administrator',
role_description='Full access to all settings',
parent_role='project.admin_role',
permissions = {'all': True}
)
auditor_role = ImplicitRoleField(
role_name='Job Template Auditor',
role_description='Read-only access to all settings',
parent_role='project.auditor_role',
permissions = {'read': True}
)
executor_role = ImplicitRoleField(
role_name='Job Template Executor',
role_name='Job Template Runner',
role_description='May run the job template',
permissions = {'read': True, 'execute': True}
)
@ -347,6 +352,20 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
def _can_update(self):
return self.can_start_without_user_input()
@property
def notifiers(self):
# Return all notifiers defined on the Job Template, on the Project, and on the Organization for each trigger type
# TODO: Currently there is no org fk on project so this will need to be added once that is
# available after the rbac pr
base_notifiers = Notifier.objects.filter(active=True)
error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_errors__in=[self, self.project]))
success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_success__in=[self, self.project]))
any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_any__in=[self, self.project]))
# Get Organization Notifiers
error_notifiers = set(error_notifiers + list(base_notifiers.filter(organization_notifiers_for_errors__in=self.project.organizations.all())))
success_notifiers = set(success_notifiers + list(base_notifiers.filter(organization_notifiers_for_success__in=self.project.organizations.all())))
any_notifiers = set(any_notifiers + list(base_notifiers.filter(organization_notifiers_for_any__in=self.project.organizations.all())))
return dict(error=list(error_notifiers), success=list(success_notifiers), any=list(any_notifiers))
class Job(UnifiedJob, JobOptions):
'''
@ -386,6 +405,9 @@ class Job(UnifiedJob, JobOptions):
def get_absolute_url(self):
return reverse('api:job_detail', args=(self.pk,))
def get_ui_url(self):
return urljoin(tower_settings.TOWER_URL_BASE, "/#/jobs/{}".format(self.pk))
@property
def task_auth_token(self):
'''Return temporary auth token used for task requests via API.'''
@ -502,6 +524,26 @@ class Job(UnifiedJob, JobOptions):
dependencies.append(source.create_inventory_update(launch_type='dependency'))
return dependencies
def notification_data(self):
data = super(Job, self).notification_data()
all_hosts = {}
for h in self.job_host_summaries.all():
all_hosts[h.host.name] = dict(failed=h.failed,
changed=h.changed,
dark=h.dark,
failures=h.failures,
ok=h.ok,
processed=h.processed,
skipped=h.skipped)
data.update(dict(inventory=self.inventory.name,
project=self.project.name,
playbook=self.playbook,
credential=self.credential.name,
limit=self.limit,
extra_vars=self.extra_vars,
hosts=all_hosts))
return data
def handle_extra_data(self, extra_data):
extra_vars = {}
if isinstance(extra_data, dict):
@ -1082,6 +1124,9 @@ class SystemJob(UnifiedJob, SystemJobOptions):
def get_absolute_url(self):
return reverse('api:system_job_detail', args=(self.pk,))
def get_ui_url(self):
return urljoin(tower_settings.TOWER_URL_BASE, "/#/management_jobs/{}".format(self.pk))
def is_blocked_by(self, obj):
return True

View File

@ -0,0 +1,172 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import logging
from django.db import models
from django.core.urlresolvers import reverse
from django.core.mail.message import EmailMessage
from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import smart_str
from awx.main.models.base import * # noqa
from awx.main.utils import encrypt_field, decrypt_field
from awx.main.notifications.email_backend import CustomEmailBackend
from awx.main.notifications.slack_backend import SlackBackend
from awx.main.notifications.twilio_backend import TwilioBackend
from awx.main.notifications.pagerduty_backend import PagerDutyBackend
from awx.main.notifications.hipchat_backend import HipChatBackend
from awx.main.notifications.webhook_backend import WebhookBackend
from awx.main.notifications.irc_backend import IrcBackend
# Django-JSONField
from jsonfield import JSONField
logger = logging.getLogger('awx.main.models.notifications')
__all__ = ['Notifier', 'Notification']
class Notifier(CommonModel):
NOTIFICATION_TYPES = [('email', _('Email'), CustomEmailBackend),
('slack', _('Slack'), SlackBackend),
('twilio', _('Twilio'), TwilioBackend),
('pagerduty', _('Pagerduty'), PagerDutyBackend),
('hipchat', _('HipChat'), HipChatBackend),
('webhook', _('Webhook'), WebhookBackend),
('irc', _('IRC'), IrcBackend)]
NOTIFICATION_TYPE_CHOICES = [(x[0], x[1]) for x in NOTIFICATION_TYPES]
CLASS_FOR_NOTIFICATION_TYPE = dict([(x[0], x[2]) for x in NOTIFICATION_TYPES])
class Meta:
app_label = 'main'
organization = models.ForeignKey(
'Organization',
blank=False,
null=True,
on_delete=models.SET_NULL,
related_name='notifiers',
)
notification_type = models.CharField(
max_length = 32,
choices=NOTIFICATION_TYPE_CHOICES,
)
notification_configuration = JSONField(blank=False)
def get_absolute_url(self):
return reverse('api:notifier_detail', args=(self.pk,))
@property
def notification_class(self):
return self.CLASS_FOR_NOTIFICATION_TYPE[self.notification_type]
def save(self, *args, **kwargs):
new_instance = not bool(self.pk)
update_fields = kwargs.get('update_fields', [])
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
self.notification_class.init_parameters):
if new_instance:
value = self.notification_configuration[field]
setattr(self, '_saved_{}_{}'.format("config", field), value)
self.notification_configuration[field] = ''
else:
encrypted = encrypt_field(self, 'notification_configuration', subfield=field)
self.notification_configuration[field] = encrypted
if 'notification_configuration' not in update_fields:
update_fields.append('notification_configuration')
super(Notifier, self).save(*args, **kwargs)
if new_instance:
update_fields = []
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
self.notification_class.init_parameters):
saved_value = getattr(self, '_saved_{}_{}'.format("config", field), '')
self.notification_configuration[field] = saved_value
#setattr(self.notification_configuration, field, saved_value)
if 'notification_configuration' not in update_fields:
update_fields.append('notification_configuration')
self.save(update_fields=update_fields)
@property
def recipients(self):
return self.notification_configuration[self.notification_class.recipient_parameter]
def generate_notification(self, subject, message):
notification = Notification(notifier=self,
notification_type=self.notification_type,
recipients=smart_str(self.recipients),
subject=subject,
body=message)
notification.save()
return notification
def send(self, subject, body):
for field in filter(lambda x: self.notification_class.init_parameters[x]['type'] == "password",
self.notification_class.init_parameters):
self.notification_configuration[field] = decrypt_field(self,
'notification_configuration',
subfield=field)
recipients = self.notification_configuration.pop(self.notification_class.recipient_parameter)
if not isinstance(recipients, list):
recipients = [recipients]
sender = self.notification_configuration.pop(self.notification_class.sender_parameter, None)
backend_obj = self.notification_class(**self.notification_configuration)
notification_obj = EmailMessage(subject, backend_obj.format_body(body), sender, recipients)
return backend_obj.send_messages([notification_obj])
class Notification(CreatedModifiedModel):
'''
A notification event emitted when a Notifier is run
'''
NOTIFICATION_STATE_CHOICES = [
('pending', _('Pending')),
('successful', _('Successful')),
('failed', _('Failed')),
]
class Meta:
app_label = 'main'
ordering = ('pk',)
notifier = models.ForeignKey(
'Notifier',
related_name='notifications',
on_delete=models.CASCADE,
editable=False
)
status = models.CharField(
max_length=20,
choices=NOTIFICATION_STATE_CHOICES,
default='pending',
editable=False,
)
error = models.TextField(
blank=True,
default='',
editable=False,
)
notifications_sent = models.IntegerField(
default=0,
editable=False,
)
notification_type = models.CharField(
max_length = 32,
choices=Notifier.NOTIFICATION_TYPE_CHOICES,
)
recipients = models.TextField(
blank=True,
default='',
editable=False,
)
subject = models.TextField(
blank=True,
default='',
editable=False,
)
body = JSONField(blank=True)
def get_absolute_url(self):
return reverse('api:notification_detail', args=(self.pk,))

View File

@ -29,7 +29,7 @@ from awx.main.conf import tower_settings
__all__ = ['Organization', 'Team', 'Permission', 'Profile', 'AuthToken']
class Organization(CommonModel, ResourceMixin):
class Organization(CommonModel, NotificationFieldsModel, ResourceMixin):
'''
An organization is the basic unit of multi-tenancy divisions
'''
@ -55,16 +55,20 @@ class Organization(CommonModel, ResourceMixin):
)
admin_role = ImplicitRoleField(
role_name='Organization Administrator',
role_description='May manage all aspects of this organization',
parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
permissions = ALL_PERMISSIONS,
)
auditor_role = ImplicitRoleField(
role_name='Organization Auditor',
role_description='May read all settings associated with this organization',
parent_role='singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR,
permissions = {'read': True}
)
member_role = ImplicitRoleField(
role_name='Organization Member',
role_description='A member of this organization',
parent_role='admin_role',
permissions = {'read': True}
)
@ -111,16 +115,19 @@ class Team(CommonModelNameNotUnique, ResourceMixin):
)
admin_role = ImplicitRoleField(
role_name='Team Administrator',
role_description='May manage this team',
parent_role='organization.admin_role',
permissions = ALL_PERMISSIONS,
)
auditor_role = ImplicitRoleField(
role_name='Team Auditor',
role_description='May read all settings associated with this team',
parent_role='organization.auditor_role',
permissions = {'read': True}
)
member_role = ImplicitRoleField(
role_name='Team Member',
role_description='A member of this team',
parent_role='admin_role',
permissions = {'read':True},
)

View File

@ -20,10 +20,12 @@ from django.utils.timezone import now, make_aware, get_default_timezone
from awx.lib.compat import slugify
from awx.main.models.base import * # noqa
from awx.main.models.jobs import Job
from awx.main.models.notifications import Notifier
from awx.main.models.unified_jobs import * # noqa
from awx.main.models.mixins import ResourceMixin
from awx.main.utils import update_scm_url
from awx.main.fields import ImplicitRoleField
from awx.main.conf import tower_settings
__all__ = ['Project', 'ProjectUpdate']
@ -209,20 +211,24 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin):
)
admin_role = ImplicitRoleField(
role_name='Project Administrator',
role_description='May manage this project',
parent_role='organizations.admin_role',
permissions = {'all': True}
)
auditor_role = ImplicitRoleField(
role_name='Project Auditor',
role_description='May read all settings associated with this project',
parent_role='organizations.auditor_role',
permissions = {'read': True}
)
member_role = ImplicitRoleField(
role_name='Project Member',
role_description='Implies membership within this project',
permissions = {'read': True}
)
scm_update_role = ImplicitRoleField(
role_name='Project Updater',
role_description='May update this project from the source control management system',
parent_role='admin_role',
permissions = {'scm_update': True}
)
@ -330,6 +336,18 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin):
return True
return False
@property
def notifiers(self):
base_notifiers = Notifier.objects.filter(active=True)
error_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_errors=self))
success_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_success=self))
any_notifiers = list(base_notifiers.filter(unifiedjobtemplate_notifiers_for_any=self))
# Get Organization Notifiers
error_notifiers = set(error_notifiers + list(base_notifiers.filter(organization_notifiers_for_errors__in=self.organizations.all())))
success_notifiers = set(success_notifiers + list(base_notifiers.filter(organization_notifiers_for_success__in=self.organizations.all())))
any_notifiers = set(any_notifiers + list(base_notifiers.filter(organization_notifiers_for_any__in=self.organizations.all())))
return dict(error=list(error_notifiers), success=list(success_notifiers), any=list(any_notifiers))
def get_absolute_url(self):
return reverse('api:project_detail', args=(self.pk,))
@ -391,6 +409,9 @@ class ProjectUpdate(UnifiedJob, ProjectOptions):
def get_absolute_url(self):
return reverse('api:project_update_detail', args=(self.pk,))
def get_ui_url(self):
return urlparse.urljoin(tower_settings.TOWER_URL_BASE, "/#/scm_update/{}".format(self.pk))
def _update_parent_instance(self):
parent_instance = self._get_parent_instance()
if parent_instance:

View File

@ -6,6 +6,7 @@ import logging
# Django
from django.db import models
from django.db.models import Q
from django.db.models.aggregates import Max
from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _
@ -128,6 +129,10 @@ class Role(CommonModelNameNotUnique):
setattr(permission, k, int(permissions[k]))
permission.save()
@staticmethod
def visible_roles(user):
return Role.objects.filter(Q(descendents__in=user.roles.filter()) | Q(ancestors__in=user.roles.filter()))
@staticmethod
def singleton(name):
try:

View File

@ -17,6 +17,7 @@ from django.db import models
from django.core.exceptions import NON_FIELD_ERRORS
from django.utils.translation import ugettext_lazy as _
from django.utils.timezone import now
from django.utils.encoding import smart_text
# Django-JSONField
from jsonfield import JSONField
@ -40,7 +41,7 @@ logger = logging.getLogger('awx.main.models.unified_jobs')
CAN_CANCEL = ('new', 'pending', 'waiting', 'running')
class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique):
class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, NotificationFieldsModel):
'''
Concrete base class for unified job templates.
'''
@ -297,6 +298,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique):
'''
return kwargs # Override if needed in subclass.
@property
def notifiers(self):
'''
Return notifiers relevant to this Unified Job Template
'''
# NOTE: Derived classes should implement
return Notifier.objects.none()
def create_unified_job(self, **kwargs):
'''
Create a new unified job based on this unified job template.
@ -385,6 +394,11 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
editable=False,
related_name='%(class)s_blocked_jobs+',
)
notifications = models.ManyToManyField(
'Notification',
editable=False,
related_name='%(class)s_notifications',
)
cancel_flag = models.BooleanField(
blank=True,
default=False,
@ -470,6 +484,13 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
else:
return ''
def get_ui_url(self):
real_instance = self.get_real_instance()
if real_instance != self:
return real_instance.get_ui_url()
else:
return ''
@classmethod
def _get_task_class(cls):
raise NotImplementedError # Implement in subclasses.
@ -717,7 +738,17 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
tasks that might preclude creating one'''
return []
def start(self, error_callback, **kwargs):
def notification_data(self):
return dict(id=self.id,
name=self.name,
url=self.get_ui_url(),
created_by=smart_text(self.created_by),
started=self.started.isoformat(),
finished=self.finished.isoformat(),
status=self.status,
traceback=self.result_traceback)
def start(self, error_callback, success_callback, **kwargs):
'''
Start the task running via Celery.
'''
@ -743,7 +774,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
# if field not in needed])
if 'extra_vars' in kwargs:
self.handle_extra_data(kwargs['extra_vars'])
task_class().apply_async((self.pk,), opts, link_error=error_callback)
task_class().apply_async((self.pk,), opts, link_error=error_callback, link=success_callback)
return True
def signal_start(self, **kwargs):
@ -765,7 +796,7 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
# Sanity check: If we are running unit tests, then run synchronously.
if getattr(settings, 'CELERY_UNIT_TEST', False):
return self.start(None, **kwargs)
return self.start(None, None, **kwargs)
# Save the pending status, and inform the SocketIO listener.
self.update_fields(start_args=json.dumps(kwargs), status='pending')

View File

@ -26,5 +26,6 @@ class UserResource(CommonModelNameNotUnique, ResourceMixin):
admin_role = ImplicitRoleField(
role_name='User Administrator',
role_description='May manage this user',
permissions = {'all': True},
)

View File

View File

@ -0,0 +1,20 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import pprint
from django.utils.encoding import smart_text
from django.core.mail.backends.base import BaseEmailBackend
class TowerBaseEmailBackend(BaseEmailBackend):
def format_body(self, body):
if "body" in body:
body_actual = body['body']
else:
body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'],
body['id'],
body['status'],
body['url']))
body_actual += pprint.pformat(body, indent=4)
return body_actual

View File

@ -0,0 +1,28 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import pprint
from django.utils.encoding import smart_text
from django.core.mail.backends.smtp import EmailBackend
class CustomEmailBackend(EmailBackend):
init_parameters = {"host": {"label": "Host", "type": "string"},
"port": {"label": "Port", "type": "int"},
"username": {"label": "Username", "type": "string"},
"password": {"label": "Password", "type": "password"},
"use_tls": {"label": "Use TLS", "type": "bool"},
"use_ssl": {"label": "Use SSL", "type": "bool"},
"sender": {"label": "Sender Email", "type": "string"},
"recipients": {"label": "Recipient List", "type": "list"}}
recipient_parameter = "recipients"
sender_parameter = "sender"
def format_body(self, body):
body_actual = smart_text("{} #{} had status {} on Ansible Tower, view details at {}\n\n".format(body['friendly_name'],
body['id'],
body['status'],
body['url']))
body_actual += pprint.pformat(body, indent=4)
return body_actual

View File

@ -0,0 +1,49 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import logging
import requests
from django.utils.encoding import smart_text
from awx.main.notifications.base import TowerBaseEmailBackend
logger = logging.getLogger('awx.main.notifications.hipchat_backend')
class HipChatBackend(TowerBaseEmailBackend):
init_parameters = {"token": {"label": "Token", "type": "password"},
"channels": {"label": "Destination Channels", "type": "list"},
"color": {"label": "Notification Color", "type": "string"},
"api_url": {"label": "API Url (e.g: https://mycompany.hipchat.com)", "type": "string"},
"notify": {"label": "Notify channel", "type": "bool"},
"message_from": {"label": "Label to be shown with notification", "type": "string"}}
recipient_parameter = "channels"
sender_parameter = "message_from"
def __init__(self, token, color, api_url, notify, fail_silently=False, **kwargs):
super(HipChatBackend, self).__init__(fail_silently=fail_silently)
self.token = token
self.color = color
self.api_url = api_url
self.notify = notify
def send_messages(self, messages):
sent_messages = 0
for m in messages:
for rcp in m.recipients():
r = requests.post("{}/v2/room/{}/notification".format(self.api_url, rcp),
params={"auth_token": self.token},
json={"color": self.color,
"message": m.subject,
"notify": self.notify,
"from": m.from_email,
"message_format": "text"})
if r.status_code != 204:
logger.error(smart_text("Error sending messages: {}".format(r.text)))
if not self.fail_silently:
raise Exception(smart_text("Error sending message to hipchat: {}".format(r.text)))
sent_messages += 1
return sent_messages

View File

@ -0,0 +1,95 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import time
import ssl
import logging
import irc.client
from django.utils.encoding import smart_text
from awx.main.notifications.base import TowerBaseEmailBackend
logger = logging.getLogger('awx.main.notifications.irc_backend')
class IrcBackend(TowerBaseEmailBackend):
init_parameters = {"server": {"label": "IRC Server Address", "type": "string"},
"port": {"label": "IRC Server Port", "type": "int"},
"nickname": {"label": "IRC Nick", "type": "string"},
"password": {"label": "IRC Server Password", "type": "password"},
"use_ssl": {"label": "SSL Connection", "type": "bool"},
"targets": {"label": "Destination Channels or Users", "type": "list"}}
recipient_parameter = "targets"
sender_parameter = None
def __init__(self, server, port, nickname, password, use_ssl, fail_silently=False, **kwargs):
super(IrcBackend, self).__init__(fail_silently=fail_silently)
self.server = server
self.port = port
self.nickname = nickname
self.password = password if password != "" else None
self.use_ssl = use_ssl
self.connection = None
def open(self):
if self.connection is not None:
return False
if self.use_ssl:
connection_factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
else:
connection_factory = irc.connection.Factory()
try:
self.reactor = irc.client.Reactor()
self.connection = self.reactor.server().connect(
self.server,
self.port,
self.nickname,
password=self.password,
connect_factory=connection_factory,
)
except irc.client.ServerConnectionError as e:
logger.error(smart_text("Exception connecting to irc server: {}".format(e)))
if not self.fail_silently:
raise
return True
def close(self):
if self.connection is None:
return
self.connection = None
def on_connect(self, connection, event):
for c in self.channels:
if irc.client.is_channel(c):
connection.join(c)
else:
for m in self.channels[c]:
connection.privmsg(c, m.subject)
self.channels_sent += 1
def on_join(self, connection, event):
for m in self.channels[event.target]:
connection.privmsg(event.target, m.subject)
self.channels_sent += 1
def send_messages(self, messages):
if self.connection is None:
self.open()
self.channels = {}
self.channels_sent = 0
for m in messages:
for r in m.recipients():
if r not in self.channels:
self.channels[r] = []
self.channels[r].append(m)
self.connection.add_global_handler("welcome", self.on_connect)
self.connection.add_global_handler("join", self.on_join)
start_time = time.time()
process_time = time.time()
while self.channels_sent < len(self.channels) and (process_time - start_time) < 60:
self.reactor.process_once(0.1)
process_time = time.time()
self.reactor.disconnect_all()
return self.channels_sent

View File

@ -0,0 +1,49 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import logging
import pygerduty
from django.utils.encoding import smart_text
from awx.main.notifications.base import TowerBaseEmailBackend
logger = logging.getLogger('awx.main.notifications.pagerduty_backend')
class PagerDutyBackend(TowerBaseEmailBackend):
init_parameters = {"subdomain": {"label": "Pagerduty subdomain", "type": "string"},
"token": {"label": "API Token", "type": "password"},
"service_key": {"label": "API Service/Integration Key", "type": "string"},
"client_name": {"label": "Client Identifier", "type": "string"}}
recipient_parameter = "service_key"
sender_parameter = "client_name"
def __init__(self, subdomain, token, fail_silently=False, **kwargs):
super(PagerDutyBackend, self).__init__(fail_silently=fail_silently)
self.subdomain = subdomain
self.token = token
def format_body(self, body):
return body
def send_messages(self, messages):
sent_messages = 0
try:
pager = pygerduty.PagerDuty(self.subdomain, self.token)
except Exception as e:
if not self.fail_silently:
raise
logger.error(smart_text("Exception connecting to PagerDuty: {}".format(e)))
for m in messages:
try:
pager.trigger_incident(m.recipients()[0],
description=m.subject,
details=m.body,
client=m.from_email)
except Exception as e:
logger.error(smart_text("Exception sending messages: {}".format(e)))
if not self.fail_silently:
raise
return sent_messages

View File

@ -0,0 +1,52 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import logging
from slackclient import SlackClient
from django.utils.encoding import smart_text
from awx.main.notifications.base import TowerBaseEmailBackend
logger = logging.getLogger('awx.main.notifications.slack_backend')
class SlackBackend(TowerBaseEmailBackend):
init_parameters = {"token": {"label": "Token", "type": "password"},
"channels": {"label": "Destination Channels", "type": "list"}}
recipient_parameter = "channels"
sender_parameter = None
def __init__(self, token, fail_silently=False, **kwargs):
super(SlackBackend, self).__init__(fail_silently=fail_silently)
self.token = token
self.connection = None
def open(self):
if self.connection is not None:
return False
self.connection = SlackClient(self.token)
if not self.connection.rtm_connect():
if not self.fail_silently:
raise Exception("Slack Notification Token is invalid")
return True
def close(self):
if self.connection is None:
return
self.connection = None
def send_messages(self, messages):
if self.connection is None:
self.open()
sent_messages = 0
for m in messages:
try:
for r in m.recipients():
self.connection.rtm_send_message(r, m.subject)
sent_messages += 1
except Exception as e:
logger.error(smart_text("Exception sending messages: {}".format(e)))
if not self.fail_silently:
raise
return sent_messages

View File

@ -0,0 +1,48 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import logging
from twilio.rest import TwilioRestClient
from django.utils.encoding import smart_text
from awx.main.notifications.base import TowerBaseEmailBackend
logger = logging.getLogger('awx.main.notifications.twilio_backend')
class TwilioBackend(TowerBaseEmailBackend):
init_parameters = {"account_sid": {"label": "Account SID", "type": "string"},
"account_token": {"label": "Account Token", "type": "password"},
"from_number": {"label": "Source Phone Number", "type": "string"},
"to_numbers": {"label": "Destination SMS Numbers", "type": "list"}}
recipient_parameter = "to_numbers"
sender_parameter = "from_number"
def __init__(self, account_sid, account_token, fail_silently=False, **kwargs):
super(TwilioBackend, self).__init__(fail_silently=fail_silently)
self.account_sid = account_sid
self.account_token = account_token
def send_messages(self, messages):
sent_messages = 0
try:
connection = TwilioRestClient(self.account_sid, self.account_token)
except Exception as e:
if not self.fail_silently:
raise
logger.error(smart_text("Exception connecting to Twilio: {}".format(e)))
for m in messages:
try:
connection.messages.create(
to=m.to,
from_=m.from_email,
body=m.subject)
sent_messages += 1
except Exception as e:
logger.error(smart_text("Exception sending messages: {}".format(e)))
if not self.fail_silently:
raise
return sent_messages

View File

@ -0,0 +1,39 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved.
import logging
import requests
import json
from django.utils.encoding import smart_text
from awx.main.notifications.base import TowerBaseEmailBackend
logger = logging.getLogger('awx.main.notifications.webhook_backend')
class WebhookBackend(TowerBaseEmailBackend):
init_parameters = {"url": {"label": "Target URL", "type": "string"},
"headers": {"label": "HTTP Headers", "type": "object"}}
recipient_parameter = "url"
sender_parameter = None
def __init__(self, headers, fail_silently=False, **kwargs):
self.headers = headers
super(WebhookBackend, self).__init__(fail_silently=fail_silently)
def format_body(self, body):
return body
def send_messages(self, messages):
sent_messages = 0
for m in messages:
r = requests.post("{}".format(m.recipients()[0]),
data=json.dumps(m.body),
headers=self.headers)
if r.status_code >= 400:
logger.error(smart_text("Error sending notification webhook: {}".format(r.text)))
if not self.fail_silently:
raise Exception(smart_text("Error sending notification webhook: {}".format(r.text)))
sent_messages += 1
return sent_messages

View File

@ -332,6 +332,8 @@ model_serializer_mapping = {
Job: JobSerializer,
AdHocCommand: AdHocCommandSerializer,
TowerSettings: TowerSettingsSerializer,
Notifier: NotifierSerializer,
Notification: NotificationSerializer,
}
def activity_stream_create(sender, instance, created, **kwargs):

View File

@ -39,6 +39,9 @@ from celery import Task, task
from django.conf import settings
from django.db import transaction, DatabaseError
from django.utils.timezone import now
from django.utils.encoding import smart_text
from django.core.mail import send_mail
from django.contrib.auth.models import User
# AWX
from awx.lib.metrics import task_timer
@ -46,13 +49,14 @@ from awx.main.constants import CLOUD_PROVIDERS
from awx.main.models import * # noqa
from awx.main.queue import FifoQueue
from awx.main.conf import tower_settings
from awx.main.task_engine import TaskSerializer, TASK_TIMEOUT_INTERVAL
from awx.main.utils import (get_ansible_version, get_ssh_version, decrypt_field, update_scm_url,
ignore_inventory_computed_fields, emit_websocket_notification,
check_proot_installed, build_proot_temp_dir, wrap_args_with_proot)
from awx.fact.utils.connection import test_mongo_connection
__all__ = ['RunJob', 'RunSystemJob', 'RunProjectUpdate', 'RunInventoryUpdate',
'RunAdHocCommand', 'handle_work_error', 'update_inventory_computed_fields']
'RunAdHocCommand', 'handle_work_error', 'handle_work_success',
'update_inventory_computed_fields', 'send_notifications', 'run_administrative_checks']
HIDDEN_PASSWORD = '**********'
@ -64,6 +68,48 @@ Try upgrading OpenSSH or providing your private key in an different format. \
logger = logging.getLogger('awx.main.tasks')
@task()
def send_notifications(notification_list, job_id=None):
if not isinstance(notification_list, list):
raise TypeError("notification_list should be of type list")
if job_id is not None:
job_actual = UnifiedJob.objects.get(id=job_id)
for notification_id in notification_list:
notification = Notification.objects.get(id=notification_id)
try:
sent = notification.notifier.send(notification.subject, notification.body)
notification.status = "successful"
notification.notifications_sent = sent
except Exception as e:
logger.error("Send Notification Failed {}".format(e))
notification.status = "failed"
notification.error = smart_text(e)
finally:
notification.save()
if job_id is not None:
job_actual.notifications.add(notification)
@task(bind=True)
def run_administrative_checks(self):
if not tower_settings.TOWER_ADMIN_ALERTS:
return
reader = TaskSerializer()
validation_info = reader.from_database()
if validation_info.get('instance_count', 0) < 1:
return
used_percentage = float(validation_info.get('current_instances', 0)) / float(validation_info.get('instance_count', 100))
tower_admin_emails = User.objects.filter(is_superuser=True).values_list('email', flat=True)
if (used_percentage * 100) > 90:
send_mail("Ansible Tower host usage over 90%",
"Ansible Tower host usage over 90%",
tower_admin_emails,
fail_silently=True)
if validation_info.get('time_remaining', 0) < TASK_TIMEOUT_INTERVAL:
send_mail("Ansible Tower license will expire soon",
"Ansible Tower license will expire soon",
tower_admin_emails,
fail_silently=True)
@task()
def bulk_inventory_element_delete(inventory, hosts=[], groups=[]):
from awx.main.signals import disable_activity_stream
@ -134,7 +180,6 @@ def notify_task_runner(metadata_dict):
queue = FifoQueue('tower_task_manager')
queue.push(metadata_dict)
@task()
def mongodb_control(cmd):
# Sanity check: Do not send arbitrary commands.
@ -159,6 +204,39 @@ def mongodb_control(cmd):
p = subprocess.Popen('sudo mongod --shutdown -f /etc/mongod.conf', shell=True)
p.wait()
@task(bind=True)
def handle_work_success(self, result, task_actual):
if task_actual['type'] == 'project_update':
instance = ProjectUpdate.objects.get(id=task_actual['id'])
instance_name = instance.name
notifiers = instance.project.notifiers
friendly_name = "Project Update"
elif task_actual['type'] == 'inventory_update':
instance = InventoryUpdate.objects.get(id=task_actual['id'])
instance_name = instance.name
notifiers = instance.inventory_source.notifiers
friendly_name = "Inventory Update"
elif task_actual['type'] == 'job':
instance = Job.objects.get(id=task_actual['id'])
instance_name = instance.job_template.name
notifiers = instance.job_template.notifiers
friendly_name = "Job"
elif task_actual['type'] == 'ad_hoc_command':
instance = AdHocCommand.objects.get(id=task_actual['id'])
instance_name = instance.module_name
notifiers = [] # TODO: Ad-hoc commands need to notify someone
friendly_name = "AdHoc Command"
else:
return
notification_body = instance.notification_data()
notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name,
task_actual['id'],
instance_name,
notification_body['url'])
send_notifications.delay([n.generate_notification(notification_subject, notification_body)
for n in set(notifiers.get('success', []) + notifiers.get('any', []))],
job_id=task_actual['id'])
@task(bind=True)
def handle_work_error(self, task_id, subtasks=None):
print('Executing error task id %s, subtasks: %s' %
@ -173,15 +251,23 @@ def handle_work_error(self, task_id, subtasks=None):
if each_task['type'] == 'project_update':
instance = ProjectUpdate.objects.get(id=each_task['id'])
instance_name = instance.name
notifiers = instance.project.notifiers
friendly_name = "Project Update"
elif each_task['type'] == 'inventory_update':
instance = InventoryUpdate.objects.get(id=each_task['id'])
instance_name = instance.name
notifiers = instance.inventory_source.notifiers
friendly_name = "Inventory Update"
elif each_task['type'] == 'job':
instance = Job.objects.get(id=each_task['id'])
instance_name = instance.job_template.name
notifiers = instance.job_template.notifiers
friendly_name = "Job"
elif each_task['type'] == 'ad_hoc_command':
instance = AdHocCommand.objects.get(id=each_task['id'])
instance_name = instance.module_name
notifiers = []
friendly_name = "AdHoc Command"
else:
# Unknown task type
break
@ -190,6 +276,7 @@ def handle_work_error(self, task_id, subtasks=None):
first_task_id = instance.id
first_task_type = each_task['type']
first_task_name = instance_name
first_task_friendly_name = friendly_name
if instance.celery_task_id != task_id:
instance.status = 'failed'
instance.failed = True
@ -197,6 +284,16 @@ def handle_work_error(self, task_id, subtasks=None):
(first_task_type, first_task_name, first_task_id)
instance.save()
instance.socketio_emit_status("failed")
notification_body = first_task.notification_data()
notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name,
first_task_id,
first_task_name,
notification_body['url'])
notification_body['friendly_name'] = first_task_friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in set(notifiers.get('error', []) + notifiers.get('any', []))],
job_id=first_task_id)
@task()
def update_inventory_computed_fields(inventory_id, should_update_hosts=True):
@ -861,11 +958,6 @@ class RunJob(BaseTask):
'''
return getattr(tower_settings, 'AWX_PROOT_ENABLED', False)
def pre_run_hook(self, job, **kwargs):
if job.job_type == PERM_INVENTORY_SCAN:
if not test_mongo_connection():
raise RuntimeError("Fact Scan Database is offline")
def post_run_hook(self, job, **kwargs):
'''
Hook for actions to run after job/task has completed.

View File

@ -0,0 +1,283 @@
{
"ansible_all_ipv4_addresses": [
"172.17.0.7"
],
"ansible_all_ipv6_addresses": [
"fe80::42:acff:fe11:7"
],
"ansible_architecture": "x86_64",
"ansible_bios_date": "12/01/2006",
"ansible_bios_version": "VirtualBox",
"ansible_cmdline": {
"BOOT_IMAGE": "/boot/vmlinuz64",
"base": true,
"console": "tty0",
"initrd": "/boot/initrd.img",
"loglevel": "3",
"noembed": true,
"nomodeset": true,
"norestore": true,
"user": "docker",
"waitusb": "10:LABEL=boot2docker-data"
},
"ansible_date_time": {
"date": "2016-02-02",
"day": "02",
"epoch": "1454424257",
"hour": "14",
"iso8601": "2016-02-02T14:44:17Z",
"iso8601_basic": "20160202T144417348424",
"iso8601_basic_short": "20160202T144417",
"iso8601_micro": "2016-02-02T14:44:17.348496Z",
"minute": "44",
"month": "02",
"second": "17",
"time": "14:44:17",
"tz": "UTC",
"tz_offset": "+0000",
"weekday": "Tuesday",
"weekday_number": "2",
"weeknumber": "05",
"year": "2016"
},
"ansible_default_ipv4": {
"address": "172.17.0.7",
"alias": "eth0",
"broadcast": "global",
"gateway": "172.17.0.1",
"interface": "eth0",
"macaddress": "02:42:ac:11:00:07",
"mtu": 1500,
"netmask": "255.255.0.0",
"network": "172.17.0.0",
"type": "ether"
},
"ansible_default_ipv6": {},
"ansible_devices": {
"sda": {
"holders": [],
"host": "",
"model": "VBOX HARDDISK",
"partitions": {
"sda1": {
"sectors": "510015555",
"sectorsize": 512,
"size": "243.19 GB",
"start": "1975995"
},
"sda2": {
"sectors": "1975932",
"sectorsize": 512,
"size": "964.81 MB",
"start": "63"
}
},
"removable": "0",
"rotational": "0",
"scheduler_mode": "deadline",
"sectors": "512000000",
"sectorsize": "512",
"size": "244.14 GB",
"support_discard": "0",
"vendor": "ATA"
},
"sr0": {
"holders": [],
"host": "",
"model": "CD-ROM",
"partitions": {},
"removable": "1",
"rotational": "1",
"scheduler_mode": "deadline",
"sectors": "61440",
"sectorsize": "2048",
"size": "120.00 MB",
"support_discard": "0",
"vendor": "VBOX"
}
},
"ansible_distribution": "Ubuntu",
"ansible_distribution_major_version": "14",
"ansible_distribution_release": "trusty",
"ansible_distribution_version": "14.04",
"ansible_dns": {
"nameservers": [
"8.8.8.8"
]
},
"ansible_domain": "",
"ansible_env": {
"HOME": "/root",
"HOSTNAME": "ede894599989",
"LANG": "en_US.UTF-8",
"LC_ALL": "en_US.UTF-8",
"LC_MESSAGES": "en_US.UTF-8",
"LESSCLOSE": "/usr/bin/lesspipe %s %s",
"LESSOPEN": "| /usr/bin/lesspipe %s",
"LS_COLORS": "",
"OLDPWD": "/ansible",
"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
"PWD": "/ansible/examples",
"SHLVL": "1",
"_": "/usr/local/bin/ansible",
"container": "docker"
},
"ansible_eth0": {
"active": true,
"device": "eth0",
"ipv4": {
"address": "172.17.0.7",
"broadcast": "global",
"netmask": "255.255.0.0",
"network": "172.17.0.0"
},
"ipv6": [
{
"address": "fe80::42:acff:fe11:7",
"prefix": "64",
"scope": "link"
}
],
"macaddress": "02:42:ac:11:00:07",
"mtu": 1500,
"promisc": false,
"type": "ether"
},
"ansible_fips": false,
"ansible_form_factor": "Other",
"ansible_fqdn": "ede894599989",
"ansible_hostname": "ede894599989",
"ansible_interfaces": [
"lo",
"eth0"
],
"ansible_kernel": "4.1.12-boot2docker",
"ansible_lo": {
"active": true,
"device": "lo",
"ipv4": {
"address": "127.0.0.1",
"broadcast": "host",
"netmask": "255.0.0.0",
"network": "127.0.0.0"
},
"ipv6": [
{
"address": "::1",
"prefix": "128",
"scope": "host"
}
],
"mtu": 65536,
"promisc": false,
"type": "loopback"
},
"ansible_lsb": {
"codename": "trusty",
"description": "Ubuntu 14.04.3 LTS",
"id": "Ubuntu",
"major_release": "14",
"release": "14.04"
},
"ansible_machine": "x86_64",
"ansible_memfree_mb": 3746,
"ansible_memory_mb": {
"nocache": {
"free": 8896,
"used": 3638
},
"real": {
"free": 3746,
"total": 12534,
"used": 8788
},
"swap": {
"cached": 0,
"free": 4048,
"total": 4048,
"used": 0
}
},
"ansible_memtotal_mb": 12534,
"ansible_mounts": [
{
"device": "/dev/sda1",
"fstype": "ext4",
"mount": "/etc/resolv.conf",
"options": "rw,relatime,data=ordered",
"size_available": 201281392640,
"size_total": 256895700992,
"uuid": "NA"
},
{
"device": "/dev/sda1",
"fstype": "ext4",
"mount": "/etc/hostname",
"options": "rw,relatime,data=ordered",
"size_available": 201281392640,
"size_total": 256895700992,
"uuid": "NA"
},
{
"device": "/dev/sda1",
"fstype": "ext4",
"mount": "/etc/hosts",
"options": "rw,relatime,data=ordered",
"size_available": 201281392640,
"size_total": 256895700992,
"uuid": "NA"
}
],
"ansible_nodename": "ede894599989",
"ansible_os_family": "Debian",
"ansible_pkg_mgr": "apt",
"ansible_processor": [
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz",
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz",
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz",
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz",
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz",
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz",
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz",
"GenuineIntel",
"Intel(R) Core(TM) i7-4870HQ CPU @ 2.50GHz"
],
"ansible_processor_cores": 8,
"ansible_processor_count": 1,
"ansible_processor_threads_per_core": 1,
"ansible_processor_vcpus": 8,
"ansible_product_name": "VirtualBox",
"ansible_product_serial": "0",
"ansible_product_uuid": "25C5EA5A-1DF1-48D9-A2C6-81227DA153C0",
"ansible_product_version": "1.2",
"ansible_python_version": "2.7.6",
"ansible_selinux": false,
"ansible_service_mgr": "upstart",
"ansible_ssh_host_key_dsa_public": "AAAAB3NzaC1kc3MAAACBALF0xsM8UMXgSKiWNw4t19wxbxLnxQX742t/dIM0O8YLx+/lIP+Q69Dv5uoVt0zKV39eFziRlCh96qj2KYkGEJ6XfVZFnhpculL2Pv2CPpSwKuQ1vTbDO/xxUrvY+bHpfNJf9Rh69bFEE2pTsjomFPCgp8M0qGaFtwg6czSaeBONAAAAFQCGEfVtj97JiexTVRqgQITYlFp/eQAAAIEAg+S9qWn+AIb3amwVoLL/usQYOPCmZY9RVPzpkjJ6OG+HI4B7cXeauPtNTJwT0f9vGEqzf4mPpmS+aCShj6iwdmJ+cOwR5+SJlNalab3CMBoXKVLbT1J2XWFlK0szKKnoReP96IDbkAkGQ3fkm4jz0z6Wy0u6wOQVNcd4G5cwLZ4AAACAFvBm+H1LwNrwWBjWio+ayhglZ4Y25mLMEn2+dqBz0gLK5szEbft1HMPOWIVHvl6vi3v34pAJHKpxXpkLlNliTn8iw9BzCOrgP4V8sp2/85mxEuCdI1w/QERj9cHu5iS2pZ0cUwDE3pfuuGBB3IEliaJyaapowdrM8lN12jQl11E=",
"ansible_ssh_host_key_ecdsa_public": "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBHiYp4e9RfXpxDcEWpK4EuXPHW9++xcFI9hiB0TYAZgxEF9RIgwfucpPawFk7HIFoNc7EXQMlryilLSbg155KWM=",
"ansible_ssh_host_key_ed25519_public": "AAAAC3NzaC1lZDI1NTE5AAAAILclD2JaC654azEsAfcHRIOA2Ig9/Qk6MX80i/VCEdSH",
"ansible_ssh_host_key_rsa_public": "AAAAB3NzaC1yc2EAAAADAQABAAABAQDeSUGxZaZsgBsezld0mj3HcbAwx6aykGnejceBjcs6lVwSGMHevofzSXIQDPYBhZoyWNl0PYAHv6AsQ8+3khd2SitUMJAuHSz1ZjgHCCGQP9ijXTKHn+lWCKA8rhLG/dwYwiouoOPZfn1G+erbKO6XiVbELrrf2RadnMGuMinESIOKVj3IunXsaGRMsDOQferOnUf7MvH7xpQnoySyQ1+p4rGruaohWG+Y2cDo7+B2FylPVbrpRDDJkfbt4J96WHx0KOdD0qzOicQP8JqDflqQPJJCWcgrvjQOSe4gXdPB6GZDtBl2qgQRwt1IgizPMm+b7Bwbd2VDe1TeWV2gT/7H",
"ansible_swapfree_mb": 4048,
"ansible_swaptotal_mb": 4048,
"ansible_system": "Linux",
"ansible_system_vendor": "innotek GmbH",
"ansible_uptime_seconds": 178398,
"ansible_user_dir": "/root",
"ansible_user_gecos": "root",
"ansible_user_gid": 0,
"ansible_user_id": "root",
"ansible_user_shell": "/bin/bash",
"ansible_user_uid": 0,
"ansible_userspace_architecture": "x86_64",
"ansible_userspace_bits": "64",
"ansible_virtualization_role": "guest",
"ansible_virtualization_type": "docker",
"module_setup": true
}

View File

@ -0,0 +1,255 @@
# Python
import mock
import pytest
from datetime import timedelta
import urlparse
import urllib
# AWX
from awx.main.models.fact import Fact
from awx.main.utils import timestamp_apiformat
# Django
from django.core.urlresolvers import reverse
from django.utils import timezone
def mock_feature_enabled(feature, bypass_database=None):
return True
def mock_feature_disabled(feature, bypass_database=None):
return False
def setup_common(hosts, fact_scans, get, user, epoch=timezone.now(), get_params={}, host_count=1):
hosts = hosts(host_count=host_count)
fact_scans(fact_scans=3, timestamp_epoch=epoch)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = get(url, user('admin', True), data=get_params)
return (hosts[0], response)
def check_url(url1_full, fact_known, module):
url1_split = urlparse.urlsplit(url1_full)
url1 = url1_split.path
url1_params = urlparse.parse_qsl(url1_split.query)
url2 = reverse('api:host_fact_compare_view', args=(fact_known.host.pk,))
url2_params = [('module', module), ('datetime', timestamp_apiformat(fact_known.timestamp))]
assert url1 == url2
assert urllib.urlencode(url1_params) == urllib.urlencode(url2_params)
def check_response_facts(facts_known, response):
for i, fact_known in enumerate(facts_known):
assert fact_known.module == response.data['results'][i]['module']
assert timestamp_apiformat(fact_known.timestamp) == response.data['results'][i]['timestamp']
check_url(response.data['results'][i]['related']['fact_view'], fact_known, fact_known.module)
def check_system_tracking_feature_forbidden(response):
assert 402 == response.status_code
assert 'Your license does not permit use of system tracking.' == response.data['detail']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_get(hosts, get, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = get(url, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_options(hosts, options, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = options(url, None, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_no_facts_db(hosts, get, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = get(url, user('admin', True))
response_expected = {
'results': []
}
assert response_expected == response.data
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_basic_fields(hosts, fact_scans, get, user):
epoch = timezone.now()
search = {
'from': epoch,
'to': epoch,
}
(host, response) = setup_common(hosts, fact_scans, get, user, epoch=epoch, get_params=search)
results = response.data['results']
assert 'related' in results[0]
assert 'timestamp' in results[0]
assert 'module' in results[0]
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_basic_options_fields(hosts, fact_scans, options, user):
hosts = hosts(host_count=1)
fact_scans(fact_scans=1)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = options(url, None, user('admin', True), pk=hosts[0].id)
assert 'related' in response.data['actions']['GET']
assert 'module' in response.data['actions']['GET']
assert ("ansible", "Ansible") in response.data['actions']['GET']['module']['choices']
assert ("services", "Services") in response.data['actions']['GET']['module']['choices']
assert ("packages", "Packages") in response.data['actions']['GET']['module']['choices']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_related_fact_view(hosts, fact_scans, get, user):
epoch = timezone.now()
(host, response) = setup_common(hosts, fact_scans, get, user, epoch=epoch)
facts_known = Fact.get_timeline(host.id)
assert 9 == len(facts_known)
assert 9 == len(response.data['results'])
for i, fact_known in enumerate(facts_known):
check_url(response.data['results'][i]['related']['fact_view'], fact_known, fact_known.module)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_multiple_hosts(hosts, fact_scans, get, user):
epoch = timezone.now()
(host, response) = setup_common(hosts, fact_scans, get, user, epoch=epoch, host_count=3)
facts_known = Fact.get_timeline(host.id)
assert 9 == len(facts_known)
assert 9 == len(response.data['results'])
for i, fact_known in enumerate(facts_known):
check_url(response.data['results'][i]['related']['fact_view'], fact_known, fact_known.module)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_param_to_from(hosts, fact_scans, get, user):
epoch = timezone.now()
search = {
'from': epoch - timedelta(days=10),
'to': epoch + timedelta(days=10),
}
(host, response) = setup_common(hosts, fact_scans, get, user, epoch=epoch, get_params=search)
facts_known = Fact.get_timeline(host.id, ts_from=search['from'], ts_to=search['to'])
assert 9 == len(facts_known)
assert 9 == len(response.data['results'])
check_response_facts(facts_known, response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_param_module(hosts, fact_scans, get, user):
epoch = timezone.now()
search = {
'module': 'packages',
}
(host, response) = setup_common(hosts, fact_scans, get, user, epoch=epoch, get_params=search)
facts_known = Fact.get_timeline(host.id, module=search['module'])
assert 3 == len(facts_known)
assert 3 == len(response.data['results'])
check_response_facts(facts_known, response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_param_from(hosts, fact_scans, get, user):
epoch = timezone.now()
search = {
'from': epoch + timedelta(days=1),
}
(host, response) = setup_common(hosts, fact_scans, get, user, epoch=epoch, get_params=search)
facts_known = Fact.get_timeline(host.id, ts_from=search['from'])
assert 3 == len(facts_known)
assert 3 == len(response.data['results'])
check_response_facts(facts_known, response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_param_to(hosts, fact_scans, get, user):
epoch = timezone.now()
search = {
'to': epoch + timedelta(days=1),
}
(host, response) = setup_common(hosts, fact_scans, get, user, epoch=epoch, get_params=search)
facts_known = Fact.get_timeline(host.id, ts_to=search['to'])
assert 6 == len(facts_known)
assert 6 == len(response.data['results'])
check_response_facts(facts_known, response)
def _test_user_access_control(hosts, fact_scans, get, user_obj, team_obj):
hosts = hosts(host_count=1)
fact_scans(fact_scans=1)
team_obj.users.add(user_obj)
url = reverse('api:host_fact_versions_list', args=(hosts[0].pk,))
response = get(url, user_obj)
return response
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_normal_user_403(hosts, fact_scans, get, user, team):
user_bob = user('bob', False)
response = _test_user_access_control(hosts, fact_scans, get, user_bob, team)
assert 403 == response.status_code
assert "You do not have permission to perform this action." == response.data['detail']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_super_user_ok(hosts, fact_scans, get, user, team):
user_super = user('bob', True)
response = _test_user_access_control(hosts, fact_scans, get, user_super, team)
assert 200 == response.status_code
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_user_admin_ok(organization, hosts, fact_scans, get, user, team):
user_admin = user('johnson', False)
organization.admins.add(user_admin)
response = _test_user_access_control(hosts, fact_scans, get, user_admin, team)
assert 200 == response.status_code
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_user_admin_403(organization, organizations, hosts, fact_scans, get, user, team):
user_admin = user('johnson', False)
org2 = organizations(1)
org2[0].admins.add(user_admin)
response = _test_user_access_control(hosts, fact_scans, get, user_admin, team)
assert 403 == response.status_code

View File

@ -0,0 +1,182 @@
import mock
import pytest
import json
from awx.main.utils import timestamp_apiformat
from django.core.urlresolvers import reverse
from django.utils import timezone
def mock_feature_enabled(feature, bypass_database=None):
return True
def mock_feature_disabled(feature, bypass_database=None):
return False
# TODO: Consider making the fact_scan() fixture a Class, instead of a function, and move this method into it
def find_fact(facts, host_id, module_name, timestamp):
for f in facts:
if f.host_id == host_id and f.module == module_name and f.timestamp == timestamp:
return f
raise RuntimeError('fact <%s, %s, %s> not found in %s', (host_id, module_name, timestamp, facts))
def setup_common(hosts, fact_scans, get, user, epoch=timezone.now(), module_name='ansible', get_params={}):
hosts = hosts(host_count=1)
facts = fact_scans(fact_scans=1, timestamp_epoch=epoch)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = get(url, user('admin', True), data=get_params)
fact_known = find_fact(facts, hosts[0].id, module_name, epoch)
return (fact_known, response)
def check_system_tracking_feature_forbidden(response):
assert 402 == response.status_code
assert 'Your license does not permit use of system tracking.' == response.data['detail']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_get(hosts, get, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = get(url, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_license_options(hosts, options, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = options(url, None, user('admin', True))
check_system_tracking_feature_forbidden(response)
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_no_fact_found(hosts, get, user):
hosts = hosts(host_count=1)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = get(url, user('admin', True))
expected_response = {
"detail": "Fact not found"
}
assert 404 == response.status_code
assert expected_response == response.data
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_basic_fields(hosts, fact_scans, get, user):
hosts = hosts(host_count=1)
fact_scans(fact_scans=1)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = get(url, user('admin', True))
assert 'related' in response.data
assert 'id' in response.data
assert 'facts' in response.data
assert 'module' in response.data
assert 'host' in response.data
assert isinstance(response.data['host'], int)
assert 'summary_fields' in response.data
assert 'host' in response.data['summary_fields']
assert 'name' in response.data['summary_fields']['host']
assert 'description' in response.data['summary_fields']['host']
assert 'host' in response.data['related']
assert reverse('api:host_detail', args=(hosts[0].pk,)) == response.data['related']['host']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_content(hosts, fact_scans, get, user, fact_ansible_json):
(fact_known, response) = setup_common(hosts, fact_scans, get, user)
assert fact_known.host_id == response.data['host']
assert fact_ansible_json == json.loads(response.data['facts'])
assert timestamp_apiformat(fact_known.timestamp) == response.data['timestamp']
assert fact_known.module == response.data['module']
def _test_search_by_module(hosts, fact_scans, get, user, fact_json, module_name):
params = {
'module': module_name
}
(fact_known, response) = setup_common(hosts, fact_scans, get, user, module_name=module_name, get_params=params)
assert fact_json == json.loads(response.data['facts'])
assert timestamp_apiformat(fact_known.timestamp) == response.data['timestamp']
assert module_name == response.data['module']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_search_by_module_packages(hosts, fact_scans, get, user, fact_packages_json):
_test_search_by_module(hosts, fact_scans, get, user, fact_packages_json, 'packages')
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_search_by_module_services(hosts, fact_scans, get, user, fact_services_json):
_test_search_by_module(hosts, fact_scans, get, user, fact_services_json, 'services')
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_search_by_timestamp_and_module(hosts, fact_scans, get, user, fact_packages_json):
epoch = timezone.now()
module_name = 'packages'
(fact_known, response) = setup_common(hosts, fact_scans, get, user, module_name=module_name, epoch=epoch, get_params=dict(module=module_name, datetime=epoch))
assert fact_known.id == response.data['id']
def _test_user_access_control(hosts, fact_scans, get, user_obj, team_obj):
hosts = hosts(host_count=1)
fact_scans(fact_scans=1)
team_obj.users.add(user_obj)
url = reverse('api:host_fact_compare_view', args=(hosts[0].pk,))
response = get(url, user_obj)
return response
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_normal_user_403(hosts, fact_scans, get, user, team):
user_bob = user('bob', False)
response = _test_user_access_control(hosts, fact_scans, get, user_bob, team)
assert 403 == response.status_code
assert "You do not have permission to perform this action." == response.data['detail']
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_super_user_ok(hosts, fact_scans, get, user, team):
user_super = user('bob', True)
response = _test_user_access_control(hosts, fact_scans, get, user_super, team)
assert 200 == response.status_code
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_user_admin_ok(organization, hosts, fact_scans, get, user, team):
user_admin = user('johnson', False)
organization.admins.add(user_admin)
response = _test_user_access_control(hosts, fact_scans, get, user_admin, team)
assert 200 == response.status_code
@mock.patch('awx.api.views.feature_enabled', new=mock_feature_enabled)
@pytest.mark.ac
@pytest.mark.django_db
def test_user_admin_403(organization, organizations, hosts, fact_scans, get, user, team):
user_admin = user('johnson', False)
org2 = organizations(1)
org2[0].admins.add(user_admin)
response = _test_user_access_control(hosts, fact_scans, get, user_admin, team)
assert 403 == response.status_code

View File

@ -0,0 +1,17 @@
# TODO: As of writing this our only concern is ensuring that the fact feature is reflected in the Host endpoint.
# Other host tests should live here to make this test suite more complete.
import pytest
from django.core.urlresolvers import reverse
@pytest.mark.django_db
def test_basic_fields(hosts, fact_scans, get, user):
hosts = hosts(host_count=1)
url = reverse('api:host_detail', args=(hosts[0].pk,))
response = get(url, user('admin', True))
assert 'related' in response.data
assert 'fact_versions' in response.data['related']
assert reverse('api:host_fact_versions_list', args=(hosts[0].pk,)) == response.data['related']['fact_versions']

View File

@ -0,0 +1,109 @@
import pytest
import time
from datetime import datetime
@pytest.fixture
def fact_msg_base(inventory, hosts):
host_objs = hosts(1)
return {
'host': host_objs[0].name,
'date_key': time.mktime(datetime.utcnow().timetuple()),
'facts' : { },
'inventory_id': inventory.id
}
@pytest.fixture
def fact_msg_small(fact_msg_base):
fact_msg_base['facts'] = {
'packages': {
"accountsservice": [
{
"architecture": "amd64",
"name": "accountsservice",
"source": "apt",
"version": "0.6.35-0ubuntu7.1"
}
],
"acpid": [
{
"architecture": "amd64",
"name": "acpid",
"source": "apt",
"version": "1:2.0.21-1ubuntu2"
}
],
"adduser": [
{
"architecture": "all",
"name": "adduser",
"source": "apt",
"version": "3.113+nmu3ubuntu3"
}
],
},
'services': [
{
"name": "acpid",
"source": "sysv",
"state": "running"
},
{
"name": "apparmor",
"source": "sysv",
"state": "stopped"
},
{
"name": "atd",
"source": "sysv",
"state": "running"
},
{
"name": "cron",
"source": "sysv",
"state": "running"
}
],
'ansible': {
'ansible_fact_simple': 'hello world',
'ansible_fact_complex': {
'foo': 'bar',
'hello': [
'scooby',
'dooby',
'doo'
]
},
}
}
return fact_msg_base
'''
Facts sent from ansible to our fact cache reciever.
The fact module type is implicit i.e
Note: The 'ansible' module is an expection to this rule.
It is NOT nested in a dict, and thus does NOT contain a first-level
key of 'ansible'
{
'fact_module_name': { ... },
}
'''
@pytest.fixture
def fact_msg_ansible(fact_msg_base, fact_ansible_json):
fact_msg_base['facts'] = fact_ansible_json
return fact_msg_base
@pytest.fixture
def fact_msg_packages(fact_msg_base, fact_packages_json):
fact_msg_base['facts']['packages'] = fact_packages_json
return fact_msg_base
@pytest.fixture
def fact_msg_services(fact_msg_base, fact_services_json):
fact_msg_base['facts']['services'] = fact_services_json
return fact_msg_base

View File

@ -0,0 +1,200 @@
# Copyright (c) 2016 Ansible, Inc.
# All Rights Reserved
# Python
import pytest
import mock
from dateutil.relativedelta import relativedelta
from datetime import timedelta
# Django
from django.utils import timezone
from django.core.management.base import CommandError
# AWX
from awx.main.management.commands.cleanup_facts import CleanupFacts, Command
from awx.main.models.fact import Fact
from awx.main.models.inventory import Host
def mock_feature_enabled(feature, bypass_database=None):
return True
def mock_feature_disabled(feature, bypass_database=None):
return False
@pytest.mark.django_db
def test_cleanup_granularity(fact_scans, hosts):
epoch = timezone.now()
hosts(5)
fact_scans(10, timestamp_epoch=epoch)
fact_newest = Fact.objects.all().order_by('-timestamp').first()
timestamp_future = fact_newest.timestamp + timedelta(days=365)
granularity = relativedelta(days=2)
cleanup_facts = CleanupFacts()
deleted_count = cleanup_facts.cleanup(timestamp_future, granularity)
assert 60 == deleted_count
'''
Delete half of the scans
'''
@pytest.mark.django_db
def test_cleanup_older_than(fact_scans, hosts):
epoch = timezone.now()
hosts(5)
fact_scans(28, timestamp_epoch=epoch)
qs = Fact.objects.all().order_by('-timestamp')
fact_middle = qs[qs.count() / 2]
granularity = relativedelta()
cleanup_facts = CleanupFacts()
deleted_count = cleanup_facts.cleanup(fact_middle.timestamp, granularity)
assert 210 == deleted_count
@pytest.mark.django_db
def test_cleanup_older_than_granularity_module(fact_scans, hosts):
epoch = timezone.now()
hosts(5)
fact_scans(10, timestamp_epoch=epoch)
fact_newest = Fact.objects.all().order_by('-timestamp').first()
timestamp_future = fact_newest.timestamp + timedelta(days=365)
granularity = relativedelta(days=2)
cleanup_facts = CleanupFacts()
deleted_count = cleanup_facts.cleanup(timestamp_future, granularity, module='ansible')
assert 20 == deleted_count
'''
Reduce the granularity of half of the facts scans, by half.
'''
@pytest.mark.django_db
def test_cleanup_logic(fact_scans, hosts):
epoch = timezone.now()
hosts = hosts(5)
fact_scans(60, timestamp_epoch=epoch)
timestamp_middle = epoch + timedelta(days=30)
granularity = relativedelta(days=2)
module = 'ansible'
cleanup_facts = CleanupFacts()
cleanup_facts.cleanup(timestamp_middle, granularity, module=module)
host_ids = Host.objects.all().values_list('id', flat=True)
host_facts = {}
for host_id in host_ids:
facts = Fact.objects.filter(host__id=host_id, module=module, timestamp__lt=timestamp_middle).order_by('-timestamp')
host_facts[host_id] = facts
for host_id, facts in host_facts.iteritems():
assert 15 == len(facts)
timestamp_pivot = timestamp_middle
for fact in facts:
timestamp_pivot -= granularity
assert fact.timestamp == timestamp_pivot
@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_disabled)
@pytest.mark.django_db
@pytest.mark.license_feature
def test_system_tracking_feature_disabled(mocker):
cmd = Command()
with pytest.raises(CommandError) as err:
cmd.handle(None)
assert 'The System Tracking feature is not enabled for your Tower instance' in err.value
@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_parameters_ok(mocker):
run = mocker.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run')
kv = {
'older_than': '1d',
'granularity': '1d',
'module': None,
}
cmd = Command()
cmd.handle(None, **kv)
run.assert_called_once_with(relativedelta(days=1), relativedelta(days=1), module=None)
@pytest.mark.django_db
def test_string_time_to_timestamp_ok():
kvs = [
{
'time': '2w',
'timestamp': relativedelta(weeks=2),
'msg': '2 weeks',
},
{
'time': '23d',
'timestamp': relativedelta(days=23),
'msg': '23 days',
},
{
'time': '11m',
'timestamp': relativedelta(months=11),
'msg': '11 months',
},
{
'time': '14y',
'timestamp': relativedelta(years=14),
'msg': '14 years',
},
]
for kv in kvs:
cmd = Command()
res = cmd.string_time_to_timestamp(kv['time'])
assert kv['timestamp'] == res
@pytest.mark.django_db
def test_string_time_to_timestamp_invalid():
kvs = [
{
'time': '2weeks',
'msg': 'weeks instead of w',
},
{
'time': '2days',
'msg': 'days instead of d',
},
{
'time': '23',
'msg': 'no unit specified',
},
{
'time': None,
'msg': 'no value specified',
},
{
'time': 'zigzag',
'msg': 'random string specified',
},
]
for kv in kvs:
cmd = Command()
res = cmd.string_time_to_timestamp(kv['time'])
assert res is None
@mock.patch('awx.main.management.commands.cleanup_facts.feature_enabled', new=mock_feature_enabled)
@pytest.mark.django_db
def test_parameters_fail(mocker):
# Mock run() just in case, but it should never get called because an error should be thrown
mocker.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run')
kvs = [
{
'older_than': '1week',
'granularity': '1d',
'msg': '--older_than invalid value "1week"',
},
{
'older_than': '1d',
'granularity': '1year',
'msg': '--granularity invalid value "1year"',
}
]
for kv in kvs:
cmd = Command()
with pytest.raises(CommandError) as err:
cmd.handle(None, older_than=kv['older_than'], granularity=kv['granularity'])
assert kv['msg'] in err.value

View File

@ -0,0 +1,95 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
import pytest
from datetime import datetime
import json
# Django
# AWX
from awx.main.management.commands.run_fact_cache_receiver import FactCacheReceiver
from awx.main.models.fact import Fact
from awx.main.models.inventory import Host
# TODO: Check that timestamp and other attributes are as expected
def check_process_fact_message_module(fact_returned, data, module_name):
date_key = data['date_key']
# Ensure 1, and only 1, fact created
timestamp = datetime.fromtimestamp(date_key, None)
assert 1 == Fact.objects.all().count()
host_obj = Host.objects.get(name=data['host'], inventory__id=data['inventory_id'])
assert host_obj is not None
fact_known = Fact.get_host_fact(host_obj.id, module_name, timestamp)
assert fact_known is not None
assert fact_known == fact_returned
assert host_obj == fact_returned.host
if module_name == 'ansible':
assert data['facts'] == fact_returned.facts
else:
assert data['facts'][module_name] == fact_returned.facts
assert timestamp == fact_returned.timestamp
assert module_name == fact_returned.module
@pytest.mark.django_db
def test_process_fact_message_ansible(fact_msg_ansible):
receiver = FactCacheReceiver()
fact_returned = receiver.process_fact_message(fact_msg_ansible)
check_process_fact_message_module(fact_returned, fact_msg_ansible, 'ansible')
@pytest.mark.django_db
def test_process_fact_message_packages(fact_msg_packages):
receiver = FactCacheReceiver()
fact_returned = receiver.process_fact_message(fact_msg_packages)
check_process_fact_message_module(fact_returned, fact_msg_packages, 'packages')
@pytest.mark.django_db
def test_process_fact_message_services(fact_msg_services):
receiver = FactCacheReceiver()
fact_returned = receiver.process_fact_message(fact_msg_services)
check_process_fact_message_module(fact_returned, fact_msg_services, 'services')
'''
We pickypack our fact sending onto the Ansible fact interface.
The interface is <hostname, facts>. Where facts is a json blob of all the facts.
This makes it hard to decipher what facts are new/changed.
Because of this, we handle the same fact module data being sent multiple times
and just keep the newest version.
'''
@pytest.mark.django_db
def test_process_facts_message_ansible_overwrite(fact_scans, fact_msg_ansible):
#epoch = timezone.now()
epoch = datetime.fromtimestamp(fact_msg_ansible['date_key'])
fact_scans(fact_scans=1, timestamp_epoch=epoch)
key = 'ansible.overwrite'
value = 'hello world'
receiver = FactCacheReceiver()
receiver.process_fact_message(fact_msg_ansible)
fact_msg_ansible['facts'][key] = value
fact_returned = receiver.process_fact_message(fact_msg_ansible)
fact_obj = Fact.objects.get(id=fact_returned.id)
assert key in fact_obj.facts
assert json.loads(fact_obj.facts) == fact_msg_ansible['facts']
assert value == json.loads(fact_obj.facts)[key]
# Ensure that the message flows from the socket through to process_fact_message()
@pytest.mark.django_db
def test_run_receiver(mocker, fact_msg_ansible):
mocker.patch("awx.main.socket.Socket.listen", return_value=[fact_msg_ansible])
receiver = FactCacheReceiver()
mocker.patch.object(receiver, 'process_fact_message', return_value=None)
receiver.run_receiver(use_processing_threads=False)
receiver.process_fact_message.assert_called_once_with(fact_msg_ansible)

View File

@ -1,8 +1,23 @@
import pytest
# Python
import pytest
import mock
import json
import os
from datetime import timedelta
# Django
from django.core.urlresolvers import resolve
from django.utils.six.moves.urllib.parse import urlparse
from django.utils import timezone
from django.contrib.auth.models import User
from django.conf import settings
# AWX
from awx.main.models.projects import Project
from awx.main.models.base import PERM_INVENTORY_READ
from awx.main.models.ha import Instance
from awx.main.models.fact import Fact
from rest_framework.test import (
APIRequestFactory,
@ -10,20 +25,34 @@ from rest_framework.test import (
)
from awx.main.models.credential import Credential
from awx.main.models.projects import Project
from awx.main.models.jobs import JobTemplate
from awx.main.models.ha import Instance
from awx.main.models.inventory import (
Inventory,
Group,
)
from awx.main.models.organization import (
Organization,
Team,
Permission,
)
from awx.main.models.rbac import Role
'''
Disable all django model signals.
'''
@pytest.fixture(scope="session", autouse=False)
def disable_signals():
mocked = mock.patch('django.dispatch.Signal.send', autospec=True)
mocked.start()
'''
FIXME: Not sure how "far" just setting the BROKER_URL will get us.
We may need to incluence CELERY's configuration like we do in the old unit tests (see base.py)
Allows django signal code to execute without the need for redis
'''
@pytest.fixture(scope="session", autouse=True)
def celery_memory_broker():
settings.BROKER_URL='memory://localhost/'
@pytest.fixture
def user():
@ -60,11 +89,15 @@ def deploy_jobtemplate(project, inventory, credential):
@pytest.fixture
def team(organization):
return Team.objects.create(organization=organization, name='test-team')
return organization.teams.create(name='test-team')
@pytest.fixture
def project(organization):
prj = Project.objects.create(name="test-project", description="test-project-desc")
@mock.patch.object(Project, "update", lambda self, **kwargs: None)
def project(instance, organization):
prj = Project.objects.create(name="test-proj",
description="test-proj-desc",
scm_type="git",
scm_url="https://github.com/jlaska/ansible-playbooks")
prj.organizations.add(organization)
return prj
@ -87,7 +120,7 @@ def credential():
@pytest.fixture
def inventory(organization):
return Inventory.objects.create(name="test-inventory", organization=organization)
return organization.inventories.create(name="test-inv")
@pytest.fixture
def role():
@ -105,12 +138,43 @@ def alice(user):
def bob(user):
return user('bob', False)
@pytest.fixture
def organizations(instance):
def rf(organization_count=1):
orgs = []
for i in xrange(0, organization_count):
o = Organization.objects.create(name="test-org-%d" % i, description="test-org-desc")
orgs.append(o)
return orgs
return rf
@pytest.fixture
def group(inventory):
def g(name):
return Group.objects.create(inventory=inventory, name=name)
try:
return Group.objects.get(name=name, inventory=inventory)
except:
return Group.objects.create(inventory=inventory, name=name)
return g
@pytest.fixture
def hosts(group):
group1 = group('group-1')
def rf(host_count=1):
hosts = []
for i in xrange(0, host_count):
name = '%s-host-%s' % (group1.name, i)
(host, created) = group1.inventory.hosts.get_or_create(name=name)
if created:
group1.hosts.add(host)
hosts.append(host)
return hosts
return rf
@pytest.fixture
def permissions():
return {
@ -244,7 +308,48 @@ def options():
return response
return rf
@pytest.fixture(scope="session", autouse=True)
def celery_memory_broker():
from django.conf import settings
settings.BROKER_URL='memory://localhost/'
@pytest.fixture
def fact_scans(group, fact_ansible_json, fact_packages_json, fact_services_json):
group1 = group('group-1')
def rf(fact_scans=1, timestamp_epoch=timezone.now()):
facts_json = {}
facts = []
module_names = ['ansible', 'services', 'packages']
timestamp_current = timestamp_epoch
facts_json['ansible'] = fact_ansible_json
facts_json['packages'] = fact_packages_json
facts_json['services'] = fact_services_json
for i in xrange(0, fact_scans):
for host in group1.hosts.all():
for module_name in module_names:
facts.append(Fact.objects.create(host=host, timestamp=timestamp_current, module=module_name, facts=facts_json[module_name]))
timestamp_current += timedelta(days=1)
return facts
return rf
def _fact_json(module_name):
current_dir = os.path.dirname(os.path.realpath(__file__))
with open('%s/%s.json' % (current_dir, module_name)) as f:
return json.load(f)
@pytest.fixture
def fact_ansible_json():
return _fact_json('ansible')
@pytest.fixture
def fact_packages_json():
return _fact_json('packages')
@pytest.fixture
def fact_services_json():
return _fact_json('services')
@pytest.fixture
def permission_inv_read(organization, inventory, team):
return Permission.objects.create(inventory=inventory, team=team, permission_type=PERM_INVENTORY_READ)

View File

@ -0,0 +1,111 @@
import pytest
from datetime import timedelta
from django.utils import timezone
from awx.main.models import Fact
@pytest.mark.django_db
def test_newest_scan_exact(hosts, fact_scans):
epoch = timezone.now()
hosts = hosts(host_count=2)
facts = fact_scans(fact_scans=3, timestamp_epoch=epoch)
fact_known = None
for f in facts:
if f.host_id == hosts[0].id and f.module == 'ansible' and f.timestamp == epoch:
fact_known = f
break
fact_found = Fact.get_host_fact(hosts[0].id, 'ansible', epoch)
assert fact_found == fact_known
'''
Show me the most recent state of the sytem at any point of time.
or, said differently
For any timestamp, get the first scan that is <= the timestamp.
'''
'''
Ensure most recent scan run is the scan returned.
Query by future date.
'''
@pytest.mark.django_db
def test_newest_scan_less_than(hosts, fact_scans):
epoch = timezone.now()
timestamp_future = epoch + timedelta(days=10)
hosts = hosts(host_count=2)
facts = fact_scans(fact_scans=3, timestamp_epoch=epoch)
fact_known = None
for f in facts:
if f.host_id == hosts[0].id and f.module == 'ansible' and f.timestamp == epoch + timedelta(days=2):
fact_known = f
break
assert fact_known is not None
fact_found = Fact.get_host_fact(hosts[0].id, 'ansible', timestamp_future)
assert fact_found == fact_known
'''
Tests query Fact that is in the middle of the fact scan timeline, but not an exact timestamp.
'''
@pytest.mark.django_db
def test_query_middle_of_timeline(hosts, fact_scans):
epoch = timezone.now()
timestamp_middle = epoch + timedelta(days=1, hours=3)
hosts = hosts(host_count=2)
facts = fact_scans(fact_scans=3, timestamp_epoch=epoch)
fact_known = None
for f in facts:
if f.host_id == hosts[0].id and f.module == 'ansible' and f.timestamp == epoch + timedelta(days=1):
fact_known = f
break
assert fact_known is not None
fact_found = Fact.get_host_fact(hosts[0].id, 'ansible', timestamp_middle)
assert fact_found == fact_known
'''
Query time less than any fact scan. Should return None
'''
@pytest.mark.django_db
def test_query_result_empty(hosts, fact_scans):
epoch = timezone.now()
timestamp_less = epoch - timedelta(days=1)
hosts = hosts(host_count=2)
fact_scans(fact_scans=3, timestamp_epoch=epoch)
fact_found = Fact.get_host_fact(hosts[0].id, 'ansible', timestamp_less)
assert fact_found is None
'''
Query by fact module other than 'ansible'
'''
@pytest.mark.django_db
def test_by_module(hosts, fact_scans):
epoch = timezone.now()
hosts = hosts(host_count=2)
facts = fact_scans(fact_scans=3, timestamp_epoch=epoch)
fact_known_services = None
fact_known_packages = None
for f in facts:
if f.host_id == hosts[0].id:
if f.module == 'services' and f.timestamp == epoch:
fact_known_services = f
elif f.module == 'packages' and f.timestamp == epoch:
fact_known_packages = f
assert fact_known_services is not None
assert fact_known_packages is not None
fact_found_services = Fact.get_host_fact(hosts[0].id, 'services', epoch)
fact_found_packages = Fact.get_host_fact(hosts[0].id, 'packages', epoch)
assert fact_found_services == fact_known_services
assert fact_found_packages == fact_known_packages

View File

@ -0,0 +1,129 @@
import pytest
from datetime import timedelta
from django.utils import timezone
from awx.main.models import Fact
def setup_common(hosts, fact_scans, ts_from=None, ts_to=None, epoch=timezone.now(), module_name='ansible', ts_known=None):
hosts = hosts(host_count=2)
facts = fact_scans(fact_scans=3, timestamp_epoch=epoch)
facts_known = []
for f in facts:
if f.host.id == hosts[0].id:
if module_name and f.module != module_name:
continue
if ts_known and f.timestamp != ts_known:
continue
facts_known.append(f)
fact_objs = Fact.get_timeline(hosts[0].id, module=module_name, ts_from=ts_from, ts_to=ts_to)
return (facts_known, fact_objs)
@pytest.mark.django_db
def test_all(hosts, fact_scans):
epoch = timezone.now()
ts_from = epoch - timedelta(days=1)
ts_to = epoch + timedelta(days=10)
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from, ts_to, module_name=None, epoch=epoch)
assert 9 == len(facts_known)
assert 9 == len(fact_objs)
@pytest.mark.django_db
def test_all_ansible(hosts, fact_scans):
epoch = timezone.now()
ts_from = epoch - timedelta(days=1)
ts_to = epoch + timedelta(days=10)
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from, ts_to, epoch=epoch)
assert 3 == len(facts_known)
assert 3 == len(fact_objs)
for i in xrange(len(facts_known) - 1, 0):
assert facts_known[i].id == fact_objs[i].id
@pytest.mark.django_db
def test_empty_db(hosts, fact_scans):
hosts = hosts(host_count=2)
epoch = timezone.now()
ts_from = epoch - timedelta(days=1)
ts_to = epoch + timedelta(days=10)
fact_objs = Fact.get_timeline(hosts[0].id, 'ansible', ts_from, ts_to)
assert 0 == len(fact_objs)
@pytest.mark.django_db
def test_no_results(hosts, fact_scans):
epoch = timezone.now()
ts_from = epoch - timedelta(days=100)
ts_to = epoch - timedelta(days=50)
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from, ts_to, epoch=epoch)
assert 0 == len(fact_objs)
@pytest.mark.django_db
def test_exact_same_equal(hosts, fact_scans):
epoch = timezone.now()
ts_to = ts_from = epoch + timedelta(days=1)
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from, ts_to, ts_known=ts_to, epoch=epoch)
assert 1 == len(facts_known)
assert 1 == len(fact_objs)
assert facts_known[0].id == fact_objs[0].id
@pytest.mark.django_db
def test_exact_from_exclusive_to_inclusive(hosts, fact_scans):
epoch = timezone.now()
ts_from = epoch + timedelta(days=1)
ts_to = epoch + timedelta(days=2)
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from, ts_to, ts_known=ts_to, epoch=epoch)
assert 1 == len(facts_known)
assert 1 == len(fact_objs)
assert facts_known[0].id == fact_objs[0].id
@pytest.mark.django_db
def test_to_lte(hosts, fact_scans):
epoch = timezone.now()
ts_to = epoch + timedelta(days=1)
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from=None, ts_to=ts_to, epoch=epoch)
facts_known_subset = filter(lambda x: x.timestamp <= ts_to, facts_known)
assert 2 == len(facts_known_subset)
assert 2 == len(fact_objs)
for i in xrange(0, len(fact_objs)):
assert facts_known_subset[len(facts_known_subset) - i - 1].id == fact_objs[i].id
@pytest.mark.django_db
def test_from_gt(hosts, fact_scans):
epoch = timezone.now()
ts_from = epoch
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from=ts_from, ts_to=None, epoch=epoch)
facts_known_subset = filter(lambda x: x.timestamp > ts_from, facts_known)
assert 2 == len(facts_known_subset)
assert 2 == len(fact_objs)
for i in xrange(0, len(fact_objs)):
assert facts_known_subset[len(facts_known_subset) - i - 1].id == fact_objs[i].id
@pytest.mark.django_db
def test_no_ts(hosts, fact_scans):
epoch = timezone.now()
(facts_known, fact_objs) = setup_common(hosts, fact_scans, ts_from=None, ts_to=None, epoch=epoch)
assert 3 == len(facts_known)
assert 3 == len(fact_objs)
for i in xrange(len(facts_known) - 1, 0):
assert facts_known[i].id == fact_objs[i].id

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,697 @@
[
{
"source": "sysv",
"state": "running",
"name": "iprdump"
},
{
"source": "sysv",
"state": "running",
"name": "iprinit"
},
{
"source": "sysv",
"state": "running",
"name": "iprupdate"
},
{
"source": "sysv",
"state": "stopped",
"name": "netconsole"
},
{
"source": "sysv",
"state": "running",
"name": "network"
},
{
"source": "systemd",
"state": "stopped",
"name": "arp-ethers.service"
},
{
"source": "systemd",
"state": "running",
"name": "auditd.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "autovt@.service"
},
{
"source": "systemd",
"state": "running",
"name": "avahi-daemon.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "blk-availability.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "brandbot.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "console-getty.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "console-shell.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "cpupower.service"
},
{
"source": "systemd",
"state": "running",
"name": "crond.service"
},
{
"source": "systemd",
"state": "running",
"name": "dbus-org.fedoraproject.FirewallD1.service"
},
{
"source": "systemd",
"state": "running",
"name": "dbus-org.freedesktop.Avahi.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dbus-org.freedesktop.hostname1.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dbus-org.freedesktop.locale1.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dbus-org.freedesktop.login1.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dbus-org.freedesktop.machine1.service"
},
{
"source": "systemd",
"state": "running",
"name": "dbus-org.freedesktop.NetworkManager.service"
},
{
"source": "systemd",
"state": "running",
"name": "dbus-org.freedesktop.nm-dispatcher.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dbus-org.freedesktop.timedate1.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dbus.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "debug-shell.service"
},
{
"source": "systemd",
"state": "running",
"name": "dhcpd.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dhcpd6.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dhcrelay.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dm-event.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dnsmasq.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-cmdline.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-initqueue.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-mount.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-pre-mount.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-pre-pivot.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-pre-trigger.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-pre-udev.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "dracut-shutdown.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "ebtables.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "emergency.service"
},
{
"source": "systemd",
"state": "running",
"name": "firewalld.service"
},
{
"source": "systemd",
"state": "running",
"name": "getty@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "halt-local.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "initrd-cleanup.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "initrd-parse-etc.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "initrd-switch-root.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "initrd-udevadm-cleanup-db.service"
},
{
"source": "systemd",
"state": "running",
"name": "irqbalance.service"
},
{
"source": "systemd",
"state": "running",
"name": "kdump.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "kmod-static-nodes.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "lvm2-lvmetad.service"
},
{
"source": "systemd",
"state": "running",
"name": "lvm2-monitor.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "lvm2-pvscan@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "messagebus.service"
},
{
"source": "systemd",
"state": "running",
"name": "microcode.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "named-setup-rndc.service"
},
{
"source": "systemd",
"state": "running",
"name": "named.service"
},
{
"source": "systemd",
"state": "running",
"name": "NetworkManager-dispatcher.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "NetworkManager-wait-online.service"
},
{
"source": "systemd",
"state": "running",
"name": "NetworkManager.service"
},
{
"source": "systemd",
"state": "running",
"name": "ntpd.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "ntpdate.service"
},
{
"source": "systemd",
"state": "running",
"name": "openvpn@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-halt.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-kexec.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-poweroff.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-quit-wait.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-quit.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-read-write.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-reboot.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-start.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "plymouth-switch-root.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "polkit.service"
},
{
"source": "systemd",
"state": "running",
"name": "postfix.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "quotaon.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rc-local.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rdisc.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rescue.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-autorelabel-mark.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-autorelabel.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-configure.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-dmesg.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-domainname.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-import-state.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-loadmodules.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "rhel-readonly.service"
},
{
"source": "systemd",
"state": "running",
"name": "rsyslog.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "serial-getty@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "sshd-keygen.service"
},
{
"source": "systemd",
"state": "running",
"name": "sshd.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "sshd@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-ask-password-console.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-ask-password-plymouth.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-ask-password-wall.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-backlight@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-binfmt.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-fsck-root.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-fsck@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-halt.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-hibernate.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-hostnamed.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-hybrid-sleep.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-initctl.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-journal-flush.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-journald.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-kexec.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-localed.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-logind.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-machined.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-modules-load.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-nspawn@.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-poweroff.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-quotacheck.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-random-seed.service"
},
{
"source": "systemd",
"state": "running",
"name": "systemd-readahead-collect.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-readahead-done.service"
},
{
"source": "systemd",
"state": "running",
"name": "systemd-readahead-drop.service"
},
{
"source": "systemd",
"state": "running",
"name": "systemd-readahead-replay.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-reboot.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-remount-fs.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-shutdownd.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-suspend.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-sysctl.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-timedated.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-tmpfiles-clean.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-tmpfiles-setup-dev.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-tmpfiles-setup.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-udev-settle.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-udev-trigger.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-udevd.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-update-utmp-runlevel.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-update-utmp.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-user-sessions.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "systemd-vconsole-setup.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "teamd@.service"
},
{
"source": "systemd",
"state": "running",
"name": "tuned.service"
},
{
"source": "systemd",
"state": "running",
"name": "vmtoolsd.service"
},
{
"source": "systemd",
"state": "stopped",
"name": "wpa_supplicant.service"
}
]

View File

@ -0,0 +1,115 @@
import mock
import pytest
from awx.main.models.notifications import Notifier
from awx.main.models.inventory import Inventory, Group
from awx.main.models.jobs import JobTemplate
from django.core.urlresolvers import reverse
@pytest.fixture
def notifier():
return Notifier.objects.create(name="test-notification",
notification_type="webhook",
notification_configuration=dict(url="http://localhost",
headers={"Test": "Header"}))
@pytest.mark.django_db
def test_get_notifier_list(get, user, notifier):
url = reverse('api:notifier_list')
response = get(url, user('admin', True))
assert response.status_code == 200
assert len(response.data['results']) == 1
@pytest.mark.django_db
def test_basic_parameterization(get, post, user, organization):
u = user('admin-poster', True)
url = reverse('api:notifier_list')
response = post(url,
dict(name="test-webhook",
description="test webhook",
organization=1,
notification_type="webhook",
notification_configuration=dict(url="http://localhost",
headers={"Test": "Header"})),
u)
assert response.status_code == 201
url = reverse('api:notifier_detail', args=(response.data['id'],))
response = get(url, u)
assert 'related' in response.data
assert 'organization' in response.data['related']
assert 'summary_fields' in response.data
assert 'organization' in response.data['summary_fields']
assert 'notifications' in response.data['related']
assert 'notification_configuration' in response.data
assert 'url' in response.data['notification_configuration']
assert 'headers' in response.data['notification_configuration']
@pytest.mark.django_db
def test_encrypted_subfields(get, post, user, organization):
def assert_send(self, messages):
assert self.account_token == "shouldhide"
return 1
u = user('admin-poster', True)
url = reverse('api:notifier_list')
response = post(url,
dict(name="test-twilio",
description="test twilio",
organization=organization.id,
notification_type="twilio",
notification_configuration=dict(account_sid="dummy",
account_token="shouldhide",
from_number="+19999999999",
to_numbers=["9998887777"])),
u)
assert response.status_code == 201
notifier_actual = Notifier.objects.get(id=response.data['id'])
url = reverse('api:notifier_detail', args=(response.data['id'],))
response = get(url, u)
assert response.data['notification_configuration']['account_token'] == "$encrypted$"
with mock.patch.object(notifier_actual.notification_class, "send_messages", assert_send):
notifier_actual.send("Test", {'body': "Test"})
@pytest.mark.django_db
def test_inherited_notifiers(get, post, user, organization, project):
u = user('admin-poster', True)
url = reverse('api:notifier_list')
notifiers = []
for nfiers in xrange(3):
response = post(url,
dict(name="test-webhook-{}".format(nfiers),
description="test webhook {}".format(nfiers),
organization=1,
notification_type="webhook",
notification_configuration=dict(url="http://localhost",
headers={"Test": "Header"})),
u)
assert response.status_code == 201
notifiers.append(response.data['id'])
organization.projects.add(project)
i = Inventory.objects.create(name='test', organization=organization)
i.save()
g = Group.objects.create(name='test', inventory=i)
g.save()
jt = JobTemplate.objects.create(name='test', inventory=i, project=project, playbook='debug.yml')
jt.save()
url = reverse('api:organization_notifiers_any_list', args=(organization.id,))
response = post(url, dict(id=notifiers[0]), u)
assert response.status_code == 204
url = reverse('api:project_notifiers_any_list', args=(project.id,))
response = post(url, dict(id=notifiers[1]), u)
assert response.status_code == 204
url = reverse('api:job_template_notifiers_any_list', args=(jt.id,))
response = post(url, dict(id=notifiers[2]), u)
assert response.status_code == 204
assert len(jt.notifiers['any']) == 3
assert len(project.notifiers['any']) == 2
assert len(g.inventory_source.notifiers['any']) == 1
@pytest.mark.django_db
def test_notifier_merging(get, post, user, organization, project, notifier):
user('admin-poster', True)
organization.projects.add(project)
organization.notifiers_any.add(notifier)
project.notifiers_any.add(notifier)
assert len(project.notifiers['any']) == 1

View File

@ -2,7 +2,7 @@ import mock # noqa
import pytest
from django.core.urlresolvers import reverse
from awx.main.models.rbac import Role
from awx.main.models.rbac import Role, ROLE_SINGLETON_SYSTEM_ADMINISTRATOR
def mock_feature_enabled(feature, bypass_database=None):
return True
@ -24,39 +24,55 @@ def test_get_roles_list_admin(organization, get, admin):
assert roles['count'] > 0
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Unimplemented')
def test_get_roles_list_user(organization, get, user):
def test_get_roles_list_user(organization, inventory, team, get, user):
'Users can see all roles they have access to, but not all roles'
assert False
this_user = user('user-test_get_roles_list_user')
organization.member_role.members.add(this_user)
custom_role = Role.objects.create(name='custom_role-test_get_roles_list_user')
organization.member_role.children.add(custom_role)
url = reverse('api:role_list')
response = get(url, this_user)
assert response.status_code == 200
roles = response.data
assert roles['count'] > 0
assert roles['count'] == len(roles['results']) # just to make sure the tests below are valid
role_hash = {}
for r in roles['results']:
role_hash[r['id']] = r
assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id in role_hash
assert organization.admin_role.id in role_hash
assert organization.member_role.id in role_hash
assert this_user.resource.admin_role.id in role_hash
assert custom_role.id in role_hash
assert inventory.admin_role.id not in role_hash
assert team.member_role.id not in role_hash
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_create_role(post, admin):
'Admins can create new roles'
#u = user('admin', True)
def test_cant_create_role(post, admin):
"Ensure we can't create new roles through the api"
# Some day we might want to do this, but until that is speced out, lets
# ensure we don't slip up and allow this implicitly through some helper or
# another
response = post(reverse('api:role_list'), {'name': 'New Role'}, admin)
assert response.status_code == 201
assert response.status_code == 405
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_delete_role(post, admin):
'Admins can delete a custom role'
assert False
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_user_create_role(organization, get, user):
'User can create custom roles'
assert False
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_user_delete_role(organization, get, user):
'User can delete their custom roles, but not any old row'
assert False
def test_cant_delete_role(delete, admin):
"Ensure we can't delete roles through the api"
# Some day we might want to do this, but until that is speced out, lets
# ensure we don't slip up and allow this implicitly through some helper or
# another
response = delete(reverse('api:role_detail', args=(admin.resource.admin_role.id,)), admin)
assert response.status_code == 405
@ -72,6 +88,53 @@ def test_get_user_roles_list(get, admin):
roles = response.data
assert roles['count'] > 0 # 'System Administrator' role if nothing else
@pytest.mark.django_db
def test_user_view_other_user_roles(organization, inventory, team, get, alice, bob):
'Users can see roles for other users, but only the roles that that user has access to see as well'
organization.member_role.members.add(alice)
organization.admins.add(bob)
custom_role = Role.objects.create(name='custom_role-test_user_view_admin_roles_list')
organization.member_role.children.add(custom_role)
team.users.add(bob)
# alice and bob are in the same org and can see some child role of that org.
# Bob is an org admin, alice can see this.
# Bob is in a team that alice is not, alice cannot see that bob is a member of that team.
url = reverse('api:user_roles_list', args=(bob.id,))
response = get(url, alice)
assert response.status_code == 200
roles = response.data
assert roles['count'] > 0
assert roles['count'] == len(roles['results']) # just to make sure the tests below are valid
role_hash = {}
for r in roles['results']:
role_hash[r['id']] = r['name']
assert organization.admin_role.id in role_hash
assert custom_role.id not in role_hash # doesn't show up in the user roles list, not an explicit grant
assert Role.singleton(ROLE_SINGLETON_SYSTEM_ADMINISTRATOR).id not in role_hash
assert inventory.admin_role.id not in role_hash
assert team.member_role.id not in role_hash # alice can't see this
# again but this time alice is part of the team, and should be able to see the team role
team.users.add(alice)
response = get(url, alice)
assert response.status_code == 200
roles = response.data
assert roles['count'] > 0
assert roles['count'] == len(roles['results']) # just to make sure the tests below are valid
role_hash = {}
for r in roles['results']:
role_hash[r['id']] = r['name']
assert team.member_role.id in role_hash # Alice can now see this
@pytest.mark.django_db
def test_add_role_to_user(role, post, admin):
assert admin.roles.filter(id=role.id).count() == 0
@ -165,15 +228,15 @@ def test_get_role(get, admin, role):
def test_put_role(put, admin, role):
url = reverse('api:role_detail', args=(role.id,))
response = put(url, {'name': 'Some new name'}, admin)
assert response.status_code == 200
r = Role.objects.get(id=role.id)
assert r.name == 'Some new name'
assert response.status_code == 405
#r = Role.objects.get(id=role.id)
#assert r.name == 'Some new name'
@pytest.mark.django_db
def test_put_role_access_denied(put, alice, admin, role):
url = reverse('api:role_detail', args=(role.id,))
response = put(url, {'name': 'Some new name'}, alice)
assert response.status_code == 403
assert response.status_code == 403 or response.status_code == 405
#
@ -204,6 +267,67 @@ def test_remove_user_to_role(post, admin, role):
post(url, {'disassociate': True, 'id': admin.id}, admin)
assert role.members.filter(id=admin.id).count() == 0
@pytest.mark.django_db
def test_org_admin_add_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user with permissions to assign/revoke membership to a particular role can do so'
org_admin = user('org-admin')
joe = user('joe')
organization.admins.add(org_admin)
assert check_jobtemplate.accessible_by(org_admin, {'write': True}) is True
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'id': joe.id}, org_admin)
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True
@pytest.mark.django_db
def test_org_admin_remove_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user with permissions to assign/revoke membership to a particular role can do so'
org_admin = user('org-admin')
joe = user('joe')
organization.admins.add(org_admin)
check_jobtemplate.executor_role.members.add(joe)
assert check_jobtemplate.accessible_by(org_admin, {'write': True}) is True
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True
post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'disassociate': True, 'id': joe.id}, org_admin)
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
@pytest.mark.django_db
def test_user_fail_to_add_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user without permissions to assign/revoke membership to a particular role cannot do so'
rando = user('rando')
joe = user('joe')
assert check_jobtemplate.accessible_by(rando, {'write': True}) is False
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
res = post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'id': joe.id}, rando)
assert res.status_code == 403
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is False
@pytest.mark.django_db
def test_user_fail_to_remove_user_to_job_template(post, organization, check_jobtemplate, user):
'Tests that a user without permissions to assign/revoke membership to a particular role cannot do so'
rando = user('rando')
joe = user('joe')
check_jobtemplate.executor_role.members.add(joe)
assert check_jobtemplate.accessible_by(rando, {'write': True}) is False
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True
res = post(reverse('api:role_users_list', args=(check_jobtemplate.executor_role.id,)), {'disassociate': True, 'id': joe.id}, rando)
assert res.status_code == 403
assert check_jobtemplate.accessible_by(joe, {'execute': True}) is True
#
# /roles/<id>/teams/
#
@ -252,22 +376,6 @@ def test_role_parents(get, team, admin, role):
assert response.data['count'] == 1
assert response.data['results'][0]['id'] == team.member_role.id
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_role_add_parent(post, team, admin, role):
assert role.parents.count() == 0
url = reverse('api:role_parents_list', args=(role.id,))
post(url, {'id': team.member_role.id}, admin)
assert role.parents.count() == 1
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_role_remove_parent(post, team, admin, role):
role.parents.add(team.member_role)
assert role.parents.count() == 1
url = reverse('api:role_parents_list', args=(role.id,))
post(url, {'disassociate': True, 'id': team.member_role.id}, admin)
assert role.parents.count() == 0
#
# /roles/<id>/children/
@ -282,22 +390,6 @@ def test_role_children(get, team, admin, role):
assert response.data['count'] == 1
assert response.data['results'][0]['id'] == role.id
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_role_add_children(post, team, admin, role):
assert role.children.count() == 0
url = reverse('api:role_children_list', args=(role.id,))
post(url, {'id': team.member_role.id}, admin)
assert role.children.count() == 1
@pytest.mark.django_db
@pytest.mark.skipif(True, reason='Waiting on custom role requirements')
def test_role_remove_children(post, team, admin, role):
role.children.add(team.member_role)
assert role.children.count() == 1
url = reverse('api:role_children_list', args=(role.id,))
post(url, {'disassociate': True, 'id': team.member_role.id}, admin)
assert role.children.count() == 0

View File

@ -138,3 +138,32 @@ def test_content_object(user):
assert org.resource.content_object.id == org.id
assert org.admin_role.content_object.id == org.id
@pytest.mark.django_db
def test_hierarchy_rebuilding():
'Tests some subdtle cases around role hierarchy rebuilding'
X = Role.objects.create(name='X')
A = Role.objects.create(name='A')
B = Role.objects.create(name='B')
C = Role.objects.create(name='C')
D = Role.objects.create(name='D')
A.children.add(B)
A.children.add(D)
B.children.add(C)
C.children.add(D)
assert A.is_ancestor_of(D)
assert X.is_ancestor_of(D) is False
X.children.add(A)
assert X.is_ancestor_of(D) is True
X.children.remove(A)
# This can be the stickler, the rebuilder needs to ensure that D's role
# hierarchy is built after both A and C are updated.
assert X.is_ancestor_of(D) is False

View File

@ -128,8 +128,8 @@ class RunAdHocCommandTest(BaseAdHocCommandTest):
self.assertFalse(ad_hoc_command.passwords_needed_to_start)
self.assertTrue(ad_hoc_command.signal_start())
ad_hoc_command = AdHocCommand.objects.get(pk=ad_hoc_command.pk)
self.check_job_result(ad_hoc_command, 'failed')
self.check_ad_hoc_command_events(ad_hoc_command, 'unreachable')
self.check_job_result(ad_hoc_command, 'successful')
self.check_ad_hoc_command_events(ad_hoc_command, 'skipped')
@mock.patch('awx.main.tasks.BaseTask.run_pexpect', return_value=('canceled', 0))
def test_cancel_ad_hoc_command(self, ignore):

View File

@ -1,238 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
from datetime import datetime
from dateutil.relativedelta import relativedelta
import mock
#Django
from django.core.management.base import CommandError
# AWX
from awx.main.tests.base import BaseTest
from awx.fact.tests.base import MongoDBRequired, FactScanBuilder, TEST_FACT_PACKAGES, TEST_FACT_ANSIBLE, TEST_FACT_SERVICES
from command_base import BaseCommandMixin
from awx.main.management.commands.cleanup_facts import Command, CleanupFacts
from awx.fact.models.fact import * # noqa
__all__ = ['CommandTest','CleanupFactsUnitTest', 'CleanupFactsCommandFunctionalTest']
class CleanupFactsCommandFunctionalTest(BaseCommandMixin, BaseTest, MongoDBRequired):
def setUp(self):
super(CleanupFactsCommandFunctionalTest, self).setUp()
self.create_test_license_file()
self.builder = FactScanBuilder()
self.builder.add_fact('ansible', TEST_FACT_ANSIBLE)
def test_invoke_zero_ok(self):
self.builder.set_epoch(datetime(year=2015, day=2, month=1, microsecond=0))
self.builder.build(scan_count=20, host_count=10)
result, stdout, stderr = self.run_command('cleanup_facts', granularity='2y', older_than='1d')
self.assertEqual(stdout, 'Deleted %s facts.\n' % ((200 / 2)))
def test_invoke_zero_deleted(self):
result, stdout, stderr = self.run_command('cleanup_facts', granularity='1w',older_than='5d')
self.assertEqual(stdout, 'Deleted 0 facts.\n')
def test_invoke_all_deleted(self):
self.builder.build(scan_count=20, host_count=10)
result, stdout, stderr = self.run_command('cleanup_facts', granularity='0d', older_than='0d')
self.assertEqual(stdout, 'Deleted 200 facts.\n')
def test_invoke_params_required(self):
result, stdout, stderr = self.run_command('cleanup_facts')
self.assertIsInstance(result, CommandError)
self.assertEqual(str(result), 'Both --granularity and --older_than are required.')
def test_module(self):
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
self.builder.add_fact('services', TEST_FACT_SERVICES)
self.builder.build(scan_count=5, host_count=5)
result, stdout, stderr = self.run_command('cleanup_facts', granularity='0d', older_than='0d', module='packages')
self.assertEqual(stdout, 'Deleted 25 facts.\n')
class CommandTest(BaseTest):
def setUp(self):
super(CommandTest, self).setUp()
self.create_test_license_file()
@mock.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run')
def test_parameters_ok(self, run):
kv = {
'older_than': '1d',
'granularity': '1d',
'module': None,
}
cmd = Command()
cmd.handle(None, **kv)
run.assert_called_once_with(relativedelta(days=1), relativedelta(days=1), module=None)
def test_string_time_to_timestamp_ok(self):
kvs = [
{
'time': '2w',
'timestamp': relativedelta(weeks=2),
'msg': '2 weeks',
},
{
'time': '23d',
'timestamp': relativedelta(days=23),
'msg': '23 days',
},
{
'time': '11m',
'timestamp': relativedelta(months=11),
'msg': '11 months',
},
{
'time': '14y',
'timestamp': relativedelta(years=14),
'msg': '14 years',
},
]
for kv in kvs:
cmd = Command()
res = cmd.string_time_to_timestamp(kv['time'])
self.assertEqual(kv['timestamp'], res, "%s should convert to %s" % (kv['time'], kv['msg']))
def test_string_time_to_timestamp_invalid(self):
kvs = [
{
'time': '2weeks',
'msg': 'weeks instead of w',
},
{
'time': '2days',
'msg': 'days instead of d',
},
{
'time': '23',
'msg': 'no unit specified',
},
{
'time': None,
'msg': 'no value specified',
},
{
'time': 'zigzag',
'msg': 'random string specified',
},
]
for kv in kvs:
cmd = Command()
res = cmd.string_time_to_timestamp(kv['time'])
self.assertIsNone(res, kv['msg'])
# Mock run() just in case, but it should never get called because an error should be thrown
@mock.patch('awx.main.management.commands.cleanup_facts.CleanupFacts.run')
def test_parameters_fail(self, run):
kvs = [
{
'older_than': '1week',
'granularity': '1d',
'msg': 'Invalid older_than param value',
},
{
'older_than': '1d',
'granularity': '1year',
'msg': 'Invalid granularity param value',
}
]
for kv in kvs:
cmd = Command()
with self.assertRaises(CommandError):
cmd.handle(None, older_than=kv['older_than'], granularity=kv['granularity'])
class CleanupFactsUnitTest(BaseCommandMixin, BaseTest, MongoDBRequired):
def setUp(self):
super(CleanupFactsUnitTest, self).setUp()
self.builder = FactScanBuilder()
self.builder.add_fact('ansible', TEST_FACT_ANSIBLE)
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
self.builder.build(scan_count=20, host_count=10)
'''
Create 10 hosts with 40 facts each. After cleanup, there should be 20 facts for each host.
Then ensure the correct facts are deleted.
'''
def test_cleanup_logic(self):
cleanup_facts = CleanupFacts()
fact_oldest = FactVersion.objects.all().order_by('timestamp').first()
granularity = relativedelta(years=2)
deleted_count = cleanup_facts.cleanup(self.builder.get_timestamp(0), granularity)
self.assertEqual(deleted_count, 2 * (self.builder.get_scan_count() * self.builder.get_host_count()) / 2)
# Check the number of facts per host
for host in self.builder.get_hosts():
count = FactVersion.objects.filter(host=host).count()
scan_count = (2 * self.builder.get_scan_count()) / 2
self.assertEqual(count, scan_count)
count = Fact.objects.filter(host=host).count()
self.assertEqual(count, scan_count)
# Ensure that only 2 facts (ansible and packages) exists per granularity time
date_pivot = self.builder.get_timestamp(0)
for host in self.builder.get_hosts():
while date_pivot > fact_oldest.timestamp:
date_pivot_next = date_pivot - granularity
kv = {
'timestamp__lte': date_pivot,
'timestamp__gt': date_pivot_next,
'host': host,
}
count = FactVersion.objects.filter(**kv).count()
self.assertEqual(count, 2, "should only be 2 FactVersion per the 2 year granularity")
count = Fact.objects.filter(**kv).count()
self.assertEqual(count, 2, "should only be 2 Fact per the 2 year granularity")
date_pivot = date_pivot_next
'''
Create 10 hosts with 40 facts each. After cleanup, there should be 30 facts for each host.
Then ensure the correct facts are deleted.
'''
def test_cleanup_module(self):
cleanup_facts = CleanupFacts()
fact_oldest = FactVersion.objects.all().order_by('timestamp').first()
granularity = relativedelta(years=2)
deleted_count = cleanup_facts.cleanup(self.builder.get_timestamp(0), granularity, module='ansible')
self.assertEqual(deleted_count, (self.builder.get_scan_count() * self.builder.get_host_count()) / 2)
# Check the number of facts per host
for host in self.builder.get_hosts():
count = FactVersion.objects.filter(host=host).count()
self.assertEqual(count, 30)
count = Fact.objects.filter(host=host).count()
self.assertEqual(count, 30)
# Ensure that only 1 ansible fact exists per granularity time
date_pivot = self.builder.get_timestamp(0)
for host in self.builder.get_hosts():
while date_pivot > fact_oldest.timestamp:
date_pivot_next = date_pivot - granularity
kv = {
'timestamp__lte': date_pivot,
'timestamp__gt': date_pivot_next,
'host': host,
'module': 'ansible',
}
count = FactVersion.objects.filter(**kv).count()
self.assertEqual(count, 1)
count = Fact.objects.filter(**kv).count()
self.assertEqual(count, 1)
date_pivot = date_pivot_next

File diff suppressed because one or more lines are too long

View File

@ -1,242 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
import unittest2 as unittest
# Django
from django.core.urlresolvers import reverse
# AWX
from awx.main.utils import timestamp_apiformat
from awx.main.models import * # noqa
from awx.main.tests.base import BaseLiveServerTest
from awx.fact.models import * # noqa
from awx.fact.tests.base import BaseFactTestMixin, FactScanBuilder, TEST_FACT_ANSIBLE, TEST_FACT_PACKAGES, TEST_FACT_SERVICES
from awx.main.utils import build_url
__all__ = ['FactVersionApiTest', 'FactViewApiTest', 'SingleFactApiTest',]
class FactApiBaseTest(BaseLiveServerTest, BaseFactTestMixin):
def setUp(self):
super(FactApiBaseTest, self).setUp()
self.create_test_license_file()
self.setup_instances()
self.setup_users()
self.organization = self.make_organization(self.super_django_user)
self.organization.admins.add(self.normal_django_user)
self.inventory = self.organization.inventories.create(name='test-inventory', description='description for test-inventory')
self.host = self.inventory.hosts.create(name='host.example.com')
self.host2 = self.inventory.hosts.create(name='host2.example.com')
self.host3 = self.inventory.hosts.create(name='host3.example.com')
def setup_facts(self, scan_count):
self.builder = FactScanBuilder()
self.builder.set_inventory_id(self.inventory.pk)
self.builder.add_fact('ansible', TEST_FACT_ANSIBLE)
self.builder.add_fact('packages', TEST_FACT_PACKAGES)
self.builder.add_fact('services', TEST_FACT_SERVICES)
self.builder.add_hostname('host.example.com')
self.builder.add_hostname('host2.example.com')
self.builder.add_hostname('host3.example.com')
self.builder.build(scan_count=scan_count, host_count=3)
self.fact_host = FactHost.objects.get(hostname=self.host.name)
class FactVersionApiTest(FactApiBaseTest):
def check_equal(self, fact_versions, results):
def find(element, set1):
for e in set1:
if all([ e.get(field) == element.get(field) for field in element.keys()]):
return e
return None
self.assertEqual(len(results), len(fact_versions))
for v in fact_versions:
v_dict = {
'timestamp': timestamp_apiformat(v.timestamp),
'module': v.module
}
e = find(v_dict, results)
self.assertIsNotNone(e, "%s not found in %s" % (v_dict, results))
def get_list(self, fact_versions, params=None):
url = build_url('api:host_fact_versions_list', args=(self.host.pk,), get=params)
with self.current_user(self.super_django_user):
response = self.get(url, expect=200)
self.check_equal(fact_versions, response['results'])
return response
def test_permission_list(self):
url = reverse('api:host_fact_versions_list', args=(self.host.pk,))
with self.current_user('admin'):
self.get(url, expect=200)
with self.current_user('normal'):
self.get(url, expect=200)
with self.current_user('other'):
self.get(url, expect=403)
with self.current_user('nobody'):
self.get(url, expect=403)
with self.current_user(None):
self.get(url, expect=401)
def test_list_empty(self):
url = reverse('api:host_fact_versions_list', args=(self.host.pk,))
with self.current_user(self.super_django_user):
response = self.get(url, expect=200)
self.assertIn('results', response)
self.assertIsInstance(response['results'], list)
self.assertEqual(len(response['results']), 0)
def test_list_related_fact_view(self):
self.setup_facts(2)
url = reverse('api:host_fact_versions_list', args=(self.host.pk,))
with self.current_user(self.super_django_user):
response = self.get(url, expect=200)
for entry in response['results']:
self.assertIn('fact_view', entry['related'])
self.get(entry['related']['fact_view'], expect=200)
def test_list(self):
self.setup_facts(2)
self.get_list(FactVersion.objects.filter(host=self.fact_host))
def test_list_module(self):
self.setup_facts(10)
self.get_list(FactVersion.objects.filter(host=self.fact_host, module='packages'), dict(module='packages'))
def test_list_time_from(self):
self.setup_facts(10)
params = {
'from': timestamp_apiformat(self.builder.get_timestamp(1)),
}
# 'to': timestamp_apiformat(self.builder.get_timestamp(3))
fact_versions = FactVersion.objects.filter(host=self.fact_host, timestamp__gt=params['from'])
self.get_list(fact_versions, params)
def test_list_time_to(self):
self.setup_facts(10)
params = {
'to': timestamp_apiformat(self.builder.get_timestamp(3))
}
fact_versions = FactVersion.objects.filter(host=self.fact_host, timestamp__lte=params['to'])
self.get_list(fact_versions, params)
def test_list_time_from_to(self):
self.setup_facts(10)
params = {
'from': timestamp_apiformat(self.builder.get_timestamp(1)),
'to': timestamp_apiformat(self.builder.get_timestamp(3))
}
fact_versions = FactVersion.objects.filter(host=self.fact_host, timestamp__gt=params['from'], timestamp__lte=params['to'])
self.get_list(fact_versions, params)
class FactViewApiTest(FactApiBaseTest):
def check_equal(self, fact_obj, results):
fact_dict = {
'timestamp': timestamp_apiformat(fact_obj.timestamp),
'module': fact_obj.module,
'host': {
'hostname': fact_obj.host.hostname,
'inventory_id': fact_obj.host.inventory_id,
'id': str(fact_obj.host.id)
},
'fact': fact_obj.fact
}
self.assertEqual(fact_dict, results)
def test_permission_view(self):
url = reverse('api:host_fact_compare_view', args=(self.host.pk,))
with self.current_user('admin'):
self.get(url, expect=200)
with self.current_user('normal'):
self.get(url, expect=200)
with self.current_user('other'):
self.get(url, expect=403)
with self.current_user('nobody'):
self.get(url, expect=403)
with self.current_user(None):
self.get(url, expect=401)
def get_fact(self, fact_obj, params=None):
url = build_url('api:host_fact_compare_view', args=(self.host.pk,), get=params)
with self.current_user(self.super_django_user):
response = self.get(url, expect=200)
self.check_equal(fact_obj, response)
def test_view(self):
self.setup_facts(2)
self.get_fact(Fact.objects.filter(host=self.fact_host, module='ansible').order_by('-timestamp')[0])
def test_view_module_filter(self):
self.setup_facts(2)
self.get_fact(Fact.objects.filter(host=self.fact_host, module='services').order_by('-timestamp')[0], dict(module='services'))
def test_view_time_filter(self):
self.setup_facts(6)
ts = self.builder.get_timestamp(3)
self.get_fact(Fact.objects.filter(host=self.fact_host, module='ansible', timestamp__lte=ts).order_by('-timestamp')[0],
dict(datetime=ts))
@unittest.skip("single fact query needs to be updated to use inventory_id attribute on host document")
class SingleFactApiTest(FactApiBaseTest):
def setUp(self):
super(SingleFactApiTest, self).setUp()
self.group = self.inventory.groups.create(name='test-group')
self.group.hosts.add(self.host, self.host2, self.host3)
def test_permission_list(self):
url = reverse('api:host_fact_versions_list', args=(self.host.pk,))
with self.current_user('admin'):
self.get(url, expect=200)
with self.current_user('normal'):
self.get(url, expect=200)
with self.current_user('other'):
self.get(url, expect=403)
with self.current_user('nobody'):
self.get(url, expect=403)
with self.current_user(None):
self.get(url, expect=401)
def _test_related(self, url):
with self.current_user(self.super_django_user):
response = self.get(url, expect=200)
self.assertTrue(len(response['results']) > 0)
for entry in response['results']:
self.assertIn('single_fact', entry['related'])
# Requires fields
self.get(entry['related']['single_fact'], expect=400)
def test_related_host_list(self):
self.setup_facts(2)
self._test_related(reverse('api:host_list'))
def test_related_group_list(self):
self.setup_facts(2)
self._test_related(reverse('api:group_list'))
def test_related_inventory_list(self):
self.setup_facts(2)
self._test_related(reverse('api:inventory_list'))
def test_params(self):
self.setup_facts(2)
params = {
'module': 'packages',
'fact_key': 'name',
'fact_value': 'acpid',
}
url = build_url('api:inventory_single_fact_view', args=(self.inventory.pk,), get=params)
with self.current_user(self.super_django_user):
response = self.get(url, expect=200)
self.assertEqual(len(response['results']), 3)
for entry in response['results']:
self.assertEqual(entry['fact'][0]['name'], 'acpid')

View File

@ -139,12 +139,13 @@ def get_encryption_key(instance, field_name):
h.update(field_name)
return h.digest()[:16]
def encrypt_field(instance, field_name, ask=False):
def encrypt_field(instance, field_name, ask=False, subfield=None):
'''
Return content of the given instance and field name encrypted.
'''
value = getattr(instance, field_name)
if isinstance(value, dict) and subfield is not None:
value = value[subfield]
if not value or value.startswith('$encrypted$') or (ask and value == 'ASK'):
return value
value = smart_str(value)
@ -157,11 +158,13 @@ def encrypt_field(instance, field_name, ask=False):
return '$encrypted$%s$%s' % ('AES', b64data)
def decrypt_field(instance, field_name):
def decrypt_field(instance, field_name, subfield=None):
'''
Return content of the given instance and field name decrypted.
'''
value = getattr(instance, field_name)
if isinstance(value, dict) and subfield is not None:
value = value[subfield]
if not value or not value.startswith('$encrypted$'):
return value
algo, b64data = value[len('$encrypted$'):].split('$', 1)

View File

@ -2,10 +2,10 @@
# This file is a utility Ansible plugin that is not part of the AWX or Ansible
# packages. It does not import any code from either package, nor does its
# license apply to Ansible or AWX.
#
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are met:
#
#
# Redistributions of source code must retain the above copyright notice, this
# list of conditions and the following disclaimer.
#
@ -90,8 +90,12 @@ CENSOR_FIELD_WHITELIST=[
'skip_reason',
]
def censor(obj):
if obj.get('_ansible_no_log', False):
def censor(obj, no_log=False):
if not isinstance(obj, dict):
if no_log:
return "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
return obj
if obj.get('_ansible_no_log', no_log):
new_obj = {}
for k in CENSOR_FIELD_WHITELIST:
if k in obj:
@ -104,8 +108,12 @@ def censor(obj):
new_obj['censored'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
obj = new_obj
if 'results' in obj:
for i in xrange(len(obj['results'])):
obj['results'][i] = censor(obj['results'][i])
if isinstance(obj['results'], list):
for i in xrange(len(obj['results'])):
obj['results'][i] = censor(obj['results'][i], obj.get('_ansible_no_log', no_log))
elif obj.get('_ansible_no_log', False):
obj['results'] = "the output has been hidden due to the fact that 'no_log: true' was specified for this result"
return obj
class TokenAuth(requests.auth.AuthBase):
@ -460,7 +468,7 @@ class JobCallbackModule(BaseCallbackModule):
# this from a normal task
self._log_event('playbook_on_task_start', task=task,
name=task.get_name())
def playbook_on_vars_prompt(self, varname, private=True, prompt=None,
encrypt=None, confirm=False, salt_size=None,
salt=None, default=None):
@ -529,6 +537,7 @@ class AdHocCommandCallbackModule(BaseCallbackModule):
def __init__(self):
self.ad_hoc_command_id = int(os.getenv('AD_HOC_COMMAND_ID', '0'))
self.rest_api_path = '/api/v1/ad_hoc_commands/%d/events/' % self.ad_hoc_command_id
self.skipped_hosts = set()
super(AdHocCommandCallbackModule, self).__init__()
def _log_event(self, event, **event_data):
@ -539,6 +548,18 @@ class AdHocCommandCallbackModule(BaseCallbackModule):
def runner_on_file_diff(self, host, diff):
pass # Ignore file diff for ad hoc commands.
def runner_on_ok(self, host, res):
# When running in check mode using a module that does not support check
# mode, Ansible v1.9 will call runner_on_skipped followed by
# runner_on_ok for the same host; only capture the skipped event and
# ignore the ok event.
if host not in self.skipped_hosts:
super(AdHocCommandCallbackModule, self).runner_on_ok(host, res)
def runner_on_skipped(self, host, item=None):
super(AdHocCommandCallbackModule, self).runner_on_skipped(host, item)
self.skipped_hosts.add(host)
if os.getenv('JOB_ID', ''):
CallbackModule = JobCallbackModule

View File

@ -342,6 +342,10 @@ CELERYBEAT_SCHEDULE = {
'task': 'awx.main.tasks.tower_periodic_scheduler',
'schedule': timedelta(seconds=30)
},
'admin_checks': {
'task': 'awx.main.tasks.run_administrative_checks',
'schedule': timedelta(days=30)
},
}
# Social Auth configuration.
@ -677,6 +681,10 @@ FACT_CACHE_PORT = 6564
ORG_ADMINS_CAN_SEE_ALL_USERS = True
TOWER_ADMIN_ALERTS = True
TOWER_URL_BASE = "https://towerhost"
TOWER_SETTINGS_MANIFEST = {
"SCHEDULE_MAX_JOBS": {
"name": "Maximum Scheduled Jobs",
@ -804,6 +812,20 @@ TOWER_SETTINGS_MANIFEST = {
"type": "bool",
"category": "system",
},
"TOWER_ADMIN_ALERTS": {
"name": "Enable Tower Administrator Alerts",
"description": "Allow Tower to email Admin users for system events that may require attention",
"default": TOWER_ADMIN_ALERTS,
"type": "bool",
"category": "system",
},
"TOWER_URL_BASE": {
"name": "Base URL of the Tower host",
"description": "This is used by services like Notifications to render a valid url to the Tower host",
"default": TOWER_URL_BASE,
"type": "string",
"category": "system",
},
"LICENSE": {
"name": "Tower License",
"description": "Controls what features and functionality is enabled in Tower.",

View File

@ -13,7 +13,6 @@ from split_settings.tools import optional, include
# Load default settings.
from defaults import * # NOQA
MONGO_HOST = '127.0.0.1'
MONGO_PORT = 27017
MONGO_USERNAME = None
@ -66,10 +65,13 @@ PASSWORD_HASHERS = (
# Configure a default UUID for development only.
SYSTEM_UUID = '00000000-0000-0000-0000-000000000000'
STATSD_CLIENT = 'django_statsd.clients.normal'
STATSD_HOST = 'graphite'
STATSD_CLIENT = 'django_statsd.clients.null'
STATSD_HOST = None
STATSD_PREFIX = None
#STATSD_CLIENT = 'django_statsd.clients.normal'
#STATSD_HOST = 'graphite'
STATSD_PORT = 8125
STATSD_PREFIX = 'tower'
#STATSD_PREFIX = 'tower'
STATSD_MAXUDPSIZE = 512
# If there is an `/etc/tower/settings.py`, include it.

View File

@ -1,29 +0,0 @@
<div id="about-dialog-body">
<div class="row">
<div class="col-xs-12 col-sm-5 About-cowsay">
<div style="width: 340px; margin: 0 auto;">
<pre id="cowsay">
________________
/ Tower Version \
\<span id='about-modal-version'></span>/
----------------
\ ^__^
\ (oo)\_______
(__)\ A)\/\
||----w |
|| ||
</pre>
</div>
</div>
<div class="col-xs-12 col-sm-7 text-center">
<img id="about-modal-titlelogo" src="/static/assets/ansible_tower_logo_minimalc.png"><br>
<p>Copyright 2015. All rights reserved.</p>
<p>Ansible and Ansible Tower are registered trademarks of Red Hat, Inc.</p>
<br>
<img class="About-redhat" src="/static/assets/redhat_ansible_lockup.png">
<br>
<p>Visit <a href="http://www.ansible.com" target="_blank">Ansible.com</a> for more information.</p>
<p><span id='about-modal-subscription'></span></p>
</div>
</div>

View File

@ -99,7 +99,9 @@ a:focus {
color: @blue-dark;
text-decoration: none;
}
.btn{
text-transform: uppercase;
}
/* Old style TB default button with grey background */
.btn-grey {
color: #333;
@ -917,15 +919,11 @@ input[type="checkbox"].checkbox-no-label {
/* Display list actions next to search widget */
.list-actions {
text-align: right;
text-align: right;
button {
margin-left: 4px;
}
.fa-lg {
vertical-align: -8%;
}
.fa-lg {
vertical-align: -8%;
}
}
.jqui-accordion {
@ -1950,11 +1948,6 @@ tr td button i {
}
}
button.dropdown-toggle,
.input-group-btn {
z-index: 1;
}
#login-modal-body {
padding-bottom: 5px;
}

View File

@ -166,9 +166,6 @@
.unreachable-hosts-color {
color: @unreachable-hosts-color;
}
.missing-hosts {
color: transparent;
}
.job_well {
padding: 8px;
@ -197,9 +194,6 @@
margin-bottom: 0;
}
#job-detail-tables {
margin-top: 20px;
}
#job_options {
height: 100px;
@ -208,7 +202,6 @@
}
#job_plays, #job_tasks {
height: 150px;
overflow-y: auto;
overflow-x: none;
}
@ -221,10 +214,7 @@
}
#job-detail-container {
position: relative;
padding-left: 15px;
padding-right: 7px;
width: 58.33333333%;
.well {
overflow: hidden;
}
@ -292,9 +282,6 @@
.row:first-child {
border: none;
}
.active {
background-color: @active-color;
}
.loading-info {
padding-top: 5px;
padding-left: 3px;
@ -329,10 +316,6 @@
text-overflow: ellipsis;
}
#tasks-table-detail {
height: 150px;
}
#play-section {
.table-detail {
height: 150px;

View File

@ -32,6 +32,7 @@ table, tbody {
background-color: @list-header-bg;
padding-left: 15px;
padding-right: 15px;
border-bottom-width:0px!important;
}
.List-tableHeader:first-of-type {
@ -69,6 +70,7 @@ table, tbody {
.List-tableCell {
padding-left: 15px;
padding-right: 15px;
border-top:0px!important;
}
.List-actionButtonCell {
@ -141,7 +143,6 @@ table, tbody {
.List-header {
display: flex;
height: 34px;
align-items: center;
}
@ -149,7 +150,7 @@ table, tbody {
align-items: center;
flex: 1 0 auto;
display: flex;
margin-top: -2px;
height: 34px;
}
.List-titleBadge {
@ -170,15 +171,22 @@ table, tbody {
text-transform: uppercase;
}
.List-actions {
.List-actionHolder {
justify-content: flex-end;
display: flex;
height: 34px;
}
.List-actions {
margin-top: -10px;
}
.List-auxAction + .List-actions {
margin-left: 10px;
}
.List-auxAction {
justify-content: flex-end;
align-items: center;
display: flex;
}
@ -186,6 +194,10 @@ table, tbody {
width: 175px;
}
.List-action:not(.ng-hide) ~ .List-action:not(.ng-hide) {
margin-left: 10px;
}
.List-buttonSubmit {
background-color: @submit-button-bg;
color: @submit-button-text;
@ -350,3 +362,25 @@ table, tbody {
display: block;
font-size: 13px;
}
@media (max-width: 991px) {
.List-searchWidget + .List-searchWidget {
margin-top: 20px;
}
}
@media (max-width: 600px) {
.List-header {
flex-direction: column;
align-items: stretch;
}
.List-actionHolder {
justify-content: flex-start;
align-items: center;
flex: 1 0 auto;
margin-top: 12px;
}
.List-well {
margin-top: 20px;
}
}

View File

@ -60,7 +60,7 @@ body {
}
#content-container {
margin-top: 40px;
padding-bottom: 40px;
}
.group-breadcrumbs {

View File

@ -32,6 +32,7 @@
#pre-container {
overflow-x: scroll;
overflow-y: auto;
padding: 10px;
}
}

View File

@ -1,14 +1,42 @@
/** @define About */
.About {
height: 309px !important;
}
@import "awx/ui/client/src/shared/branding/colors.default.less";
.About-cowsay {
margin-top: 30px;
.About-cowsay--container{
width: 340px;
margin: 0 auto;
}
.About-redhat {
max-width: 100%;
margin-top: -61px;
margin-bottom: -33px;
.About-cowsay--code{
background-color: @default-bg;
padding-left: 30px;
border-style: none;
max-width: 340px;
padding-left: 30px;
}
.About .modal-header{
border: none;
padding-bottom: 0px;
}
.About .modal-dialog{
max-width: 500px;
}
.About .modal-body{
padding-top: 0px;
}
.About-brand--redhat{
max-width: 420px;
margin: 0 auto;
margin-top: -50px;
margin-bottom: -30px;
}
.About-brand--ansible{
max-width: 120px;
margin: 0 auto;
}
.About-close{
position: absolute;
top: 15px;
right: 15px;
}
.About p{
color: @default-interface-txt;
}

View File

@ -0,0 +1,31 @@
export default
['$scope', '$state', 'CheckLicense', function($scope, $state, CheckLicense){
var processVersion = function(version){
// prettify version & calculate padding
// e,g 3.0.0-0.git201602191743/ -> 3.0.0
var split = version.split('-')[0]
var spaces = Math.floor((16-split.length)/2),
paddedStr = "";
for(var i=0; i<=spaces; i++){
paddedStr = paddedStr +" ";
}
paddedStr = paddedStr + split;
for(var j = paddedStr.length; j<16; j++){
paddedStr = paddedStr + " ";
}
return paddedStr
}
var init = function(){
CheckLicense.get()
.then(function(res){
$scope.subscription = res.data.license_info.subscription_name;
$scope.version = processVersion(res.data.version);
$('#about-modal').modal('show');
});
};
var back = function(){
$state.go('setup');
}
init();
}
];

View File

@ -0,0 +1,32 @@
<div class="About modal fade" id="about-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<img class="About-brand--ansible img-responsive" src="/static/assets/ansible_tower_logo_minimalc.png" />
<button type="button" class="close About-close" ng-click="back()">
<span class="fa fa-times-circle"></span>
</button>
</div>
<div class="modal-body">
<div class="About-cowsay--container">
<!-- Don't indent this properly, you'll break the cow -->
<pre class="About-cowsay--code">
________________
/ Tower Version \\
\\<span>{{version}}</span>/
----------------
\\ ^__^
\\ (oo)\\_______
(__)\ A)\\/\\
||----w |
|| ||
</pre>
</div>
<img class="About-brand--redhat img-responsive" src="/static/assets/redhat_ansible_lockup.png" />
<p class="text-center">Copyright 2016. All rights reserved.<br>
Ansible and Ansible Tower are registered trademarks of <a href="http://www.redhat.com/" target="_blank">Red Hat, Inc</a>.<br>
Visit <a href="http://www.ansible.com/" target="_blank">Ansible.com</a> for more information.<br>
{{subscription}}</p>
</div>
</div>
</div>

View File

@ -0,0 +1,12 @@
import {templateUrl} from '../shared/template-url/template-url.factory';
import controller from './about.controller';
export default {
name: 'setup.about',
route: '/about',
controller: controller,
ncyBreadcrumb: {
label: "ABOUT"
},
templateUrl: templateUrl('about/about')
};

View File

@ -0,0 +1,15 @@
/*************************************************
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
import controller from './about.controller';
import route from './about.route';
export default
angular.module('aboutTower', [])
.controller('aboutTower', controller)
.run(['$stateExtender', function($stateExtender){
$stateExtender.addState(route);
}]);

View File

@ -15,8 +15,8 @@ export default ['templateUrl', function(templateUrl) {
$scope.streamTarget = ($state.params && $state.params.target) ? $state.params.target : 'dashboard';
$scope.options = [
{label: 'All Activity', value: 'dashboard'},
{label: 'Credentials', value: 'credential'},
{label: 'Dashboard', value: 'dashboard'},
{label: 'Hosts', value: 'host'},
{label: 'Inventories', value: 'inventory'},
{label: 'Inventory Scripts', value: 'inventory_script'},
@ -38,11 +38,11 @@ export default ['templateUrl', function(templateUrl) {
if($scope.streamTarget && $scope.streamTarget == 'dashboard') {
// Just navigate to the base activity stream
$state.go('activityStream', {}, {inherit: false, reload: true});
$state.go('activityStream', {}, {inherit: false});
}
else {
// Attach the taget to the query parameters
$state.go('activityStream', {target: $scope.streamTarget});
$state.go('activityStream', {target: $scope.streamTarget}, {inherit: false});
}
}

View File

@ -1,11 +1,9 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
* Copyright (c) 2016 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
var urlPrefix;
if ($basePath) {
@ -34,6 +32,8 @@ import managementJobs from './management-jobs/main';
import jobDetail from './job-detail/main';
// modules
import about from './about/main';
import license from './license/main';
import setupMenu from './setup-menu/main';
import mainMenu from './main-menu/main';
import breadCrumb from './bread-crumb/main';
@ -47,7 +47,6 @@ import activityStream from './activity-stream/main';
import standardOut from './standard-out/main';
import lookUpHelper from './lookup/main';
import {JobTemplatesList, JobTemplatesAdd, JobTemplatesEdit} from './controllers/JobTemplates';
import {LicenseController} from './controllers/License';
import {ScheduleEditController} from './controllers/Schedules';
import {ProjectsList, ProjectsAdd, ProjectsEdit} from './controllers/Projects';
import {OrganizationsList, OrganizationsAdd, OrganizationsEdit} from './controllers/Organizations';
@ -80,6 +79,8 @@ var tower = angular.module('Tower', [
// 'ngAnimate',
'ngSanitize',
'ngCookies',
about.name,
license.name,
RestServices.name,
browserData.name,
systemTracking.name,
@ -100,7 +101,6 @@ var tower = angular.module('Tower', [
standardOut.name,
'templates',
'Utilities',
'LicenseHelper',
'OrganizationFormDefinition',
'UserFormDefinition',
'FormGenerator',
@ -172,7 +172,7 @@ var tower = angular.module('Tower', [
'SchedulesHelper',
'JobsListDefinition',
'LogViewerStatusDefinition',
'LogViewerHelper',
'StandardOutHelper',
'LogViewerOptionsDefinition',
'EventViewerHelper',
'HostEventsViewerHelper',
@ -181,7 +181,6 @@ var tower = angular.module('Tower', [
'lrInfiniteScroll',
'LoadConfigHelper',
'SocketHelper',
'AboutAnsibleHelpModal',
'PortalJobsListDefinition',
'features',
'longDateFilter',
@ -200,9 +199,9 @@ var tower = angular.module('Tower', [
.config(['$pendolyticsProvider', function($pendolyticsProvider) {
$pendolyticsProvider.doNotAutoStart();
}])
.config(['$stateProvider', '$urlRouterProvider', '$breadcrumbProvider',
function ($stateProvider, $urlRouterProvider, $breadcrumbProvider) {
.config(['$stateProvider', '$urlRouterProvider', '$breadcrumbProvider', '$urlMatcherFactoryProvider',
function ($stateProvider, $urlRouterProvider, $breadcrumbProvider, $urlMatcherFactoryProvider) {
$urlMatcherFactoryProvider.strictMode(false)
$breadcrumbProvider.setOptions({
templateUrl: urlPrefix + 'partials/breadcrumb.html'
});
@ -859,21 +858,6 @@ var tower = angular.module('Tower', [
}
}).
state('license', {
url: '/license',
templateUrl: urlPrefix + 'partials/license.html',
controller: LicenseController,
ncyBreadcrumb: {
parent: 'setup',
label: 'LICENSE'
},
resolve: {
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
}
}).
state('sockets', {
url: '/sockets',
templateUrl: urlPrefix + 'partials/sockets.html',
@ -898,12 +882,14 @@ var tower = angular.module('Tower', [
}]);
}])
.run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', 'CheckLicense', '$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'Socket',
'LoadConfig', 'Store', 'ShowSocketHelp', 'AboutAnsibleHelp', 'pendoService',
function ($q, $compile, $cookieStore, $rootScope, $log, CheckLicense, $location, Authorization, LoadBasePaths, Timer, ClearScope, Socket,
LoadConfig, Store, ShowSocketHelp, AboutAnsibleHelp, pendoService) {
.run(['$q', '$compile', '$cookieStore', '$rootScope', '$log', '$state', 'CheckLicense',
'$location', 'Authorization', 'LoadBasePaths', 'Timer', 'ClearScope', 'Socket',
'LoadConfig', 'Store', 'ShowSocketHelp', 'pendoService',
function (
$q, $compile, $cookieStore, $rootScope, $log, $state, CheckLicense,
$location, Authorization, LoadBasePaths, Timer, ClearScope, Socket,
LoadConfig, Store, ShowSocketHelp, pendoService)
{
var sock;
function activateTab() {
@ -976,32 +962,28 @@ var tower = angular.module('Tower', [
' status changed to ' + data.status +
' send to ' + $location.$$url);
var urlToCheck = $location.$$url;
if (urlToCheck.indexOf("?") !== -1) {
urlToCheck = urlToCheck.substr(0, urlToCheck.indexOf("?"));
}
// this acts as a router...it emits the proper
// value based on what URL the user is currently
// accessing.
if (urlToCheck === '/jobs') {
if ($state.is('jobs')) {
$rootScope.$emit('JobStatusChange-jobs', data);
} else if (/\/jobs\/(\d)+\/stdout/.test(urlToCheck) ||
/\/ad_hoc_commands\/(\d)+/.test(urlToCheck)) {
// TODO: something will need to change here for stdout
} else if ($state.is('jobDetail') ||
$state.is('adHocJobStdout') ||
$state.is('inventorySyncStdout') ||
$state.is('managementJobStdout') ||
$state.is('scmUpdateStdout')) {
$log.debug("sending status to standard out");
$rootScope.$emit('JobStatusChange-jobStdout', data);
} else if (/\/jobs\/(\d)+/.test(urlToCheck)) {
} if ($state.is('jobDetail')) {
$rootScope.$emit('JobStatusChange-jobDetails', data);
} else if (urlToCheck === '/home') {
} else if ($state.is('dashboard')) {
$rootScope.$emit('JobStatusChange-home', data);
} else if (urlToCheck === '/portal') {
} else if ($state.is('portal')) {
$rootScope.$emit('JobStatusChange-portal', data);
} else if (urlToCheck === '/projects') {
} else if ($state.is('projects')) {
$rootScope.$emit('JobStatusChange-projects', data);
} else if (/\/inventories\/(\d)+\/manage/.test(urlToCheck)) {
} else if ($state.is('inventoryManage')) {
$rootScope.$emit('JobStatusChange-inventory', data);
}
});
@ -1043,7 +1025,6 @@ var tower = angular.module('Tower', [
$rootScope.$on("$stateChangeStart", function (event, next, nextParams, prev) {
// this line removes the query params attached to a route
if(prev && prev.$$route &&
prev.$$route.name === 'systemTracking'){
@ -1083,15 +1064,15 @@ var tower = angular.module('Tower', [
if ($rootScope.current_user === undefined || $rootScope.current_user === null) {
Authorization.restoreUserInfo(); //user must have hit browser refresh
}
if (next && next.$$route && (!/^\/(login|logout)/.test(next.$$route.originalPath))) {
// if not headed to /login or /logout, then check the license
CheckLicense.test();
}
}
activateTab();
});
$rootScope.$on('$stateChangeSuccess', function(event, toState, toParams, fromState, fromParams) {
// catch license expiration notifications immediately after user logs in, redirect
if (fromState.name == 'signIn'){
CheckLicense.notify();
}
// broadcast event change if editing crud object
if ($location.$$path && $location.$$path.split("/")[3] && $location.$$path.split("/")[3] === "schedules") {
var list = $location.$$path.split("/")[3];
@ -1140,10 +1121,6 @@ var tower = angular.module('Tower', [
activateTab();
$rootScope.viewAboutTower = function(){
AboutAnsibleHelp();
};
$rootScope.viewCurrentUser = function () {
$location.path('/users/' + $rootScope.current_user.id);
};

View File

@ -149,7 +149,7 @@ Home.$inject = ['$scope', '$compile', '$stateParams', '$rootScope', '$location',
* @description This controls the 'home/groups' page that is loaded from the dashboard
*
*/
export function HomeGroups($rootScope, $log, $scope, $filter, $compile, $location, $stateParams, LogViewer, HomeGroupList, GenerateList, ProcessErrors, ReturnToCaller, ClearScope,
export function HomeGroups($rootScope, $log, $scope, $filter, $compile, $location, $stateParams, HomeGroupList, GenerateList, ProcessErrors, ReturnToCaller, ClearScope,
GetBasePath, SearchInit, PaginateInit, FormatDate, GetHostsStatusMsg, GetSyncStatusMsg, ViewUpdateStatus, GroupsEdit, Wait,
Alert, Rest, Empty, InventoryUpdate, Find, GroupsCancelUpdate, Store) {
@ -461,58 +461,6 @@ export function HomeGroups($rootScope, $log, $scope, $filter, $compile, $locatio
attachElem(event, html, title);
});
if (scope.removeGroupSummaryReady) {
scope.removeGroupSummaryReady();
}
scope.removeGroupSummaryReady = scope.$on('GroupSummaryReady', function(e, event, inventory, data) {
var html, title;
Wait('stop');
// Build the html for our popover
html = "<table class=\"table table-condensed flyout\" style=\"width: 100%\">\n";
html += "<thead>\n";
html += "<tr>";
html += "<th>Status</th>";
html += "<th>Last Sync</th>";
html += "<th>Group</th>";
html += "</tr>";
html += "</thead>\n";
html += "<tbody>\n";
data.results.forEach( function(row) {
html += "<tr>";
html += "<td><a href=\"\" ng-click=\"viewJob('" + row.related.last_update + "')\" aw-tool-tip=\"" + row.status.charAt(0).toUpperCase() + row.status.slice(1) + ". Click for details\" aw-tip-placement=\"top\"><i class=\"fa icon-job-" + row.status + "\"></i></a></td>";
html += "<td>" + ($filter('longDate')(row.last_updated)).replace(/ /,'<br />') + "</td>";
html += "<td><a href=\"\" ng-click=\"viewJob('" + row.related.last_update + "')\">" + ellipsis(row.summary_fields.group.name) + "</a></td>";
html += "</tr>\n";
});
html += "</tbody>\n";
html += "</table>\n";
title = "Sync Status";
attachElem(event, html, title);
});
scope.showGroupSummary = function(event, id) {
var group, status;
if (!Empty(id)) {
group = Find({ list: scope.home_groups, key: 'id', val: id });
status = group.summary_fields.inventory_source.status;
if (status === 'running' || status === 'failed' || status === 'error' || status === 'successful') {
Wait('start');
Rest.setUrl(group.related.inventory_sources + '?or__source=ec2&or__source=rax&order_by=-last_job_run&page_size=5');
Rest.get()
.success(function(data) {
scope.$emit('GroupSummaryReady', event, group, data);
})
.error(function(data, status) {
ProcessErrors( scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + group.related.inventory_sources + ' failed. GET returned status: ' + status
});
});
}
}
};
scope.showHostSummary = function(event, id) {
var url, jobs = [];
if (!Empty(id)) {
@ -549,13 +497,6 @@ export function HomeGroups($rootScope, $log, $scope, $filter, $compile, $locatio
}
};
scope.viewJob = function(url) {
LogViewer({
scope: modal_scope,
url: url
});
};
scope.cancelUpdate = function(id) {
var group = Find({ list: scope.home_groups, key: 'id', val: id });
GroupsCancelUpdate({ scope: scope, group: group });
@ -564,7 +505,7 @@ export function HomeGroups($rootScope, $log, $scope, $filter, $compile, $locatio
}
HomeGroups.$inject = ['$rootScope', '$log', '$scope', '$filter', '$compile', '$location', '$stateParams', 'LogViewer', 'HomeGroupList', 'generateList', 'ProcessErrors', 'ReturnToCaller',
HomeGroups.$inject = ['$rootScope', '$log', '$scope', '$filter', '$compile', '$location', '$stateParams', 'HomeGroupList', 'generateList', 'ProcessErrors', 'ReturnToCaller',
'ClearScope', 'GetBasePath', 'SearchInit', 'PaginateInit', 'FormatDate', 'GetHostsStatusMsg', 'GetSyncStatusMsg', 'ViewUpdateStatus',
'GroupsEdit', 'Wait', 'Alert', 'Rest', 'Empty', 'InventoryUpdate', 'Find', 'GroupsCancelUpdate', 'Store', 'Socket'
];
@ -578,7 +519,7 @@ HomeGroups.$inject = ['$rootScope', '$log', '$scope', '$filter', '$compile', '$l
*/
export function HomeHosts($scope, $location, $stateParams, HomeHostList, GenerateList, ProcessErrors, ReturnToCaller, ClearScope,
GetBasePath, SearchInit, PaginateInit, FormatDate, SetStatus, ToggleHostEnabled, HostsEdit, Find, ShowJobSummary, ViewJob) {
GetBasePath, SearchInit, PaginateInit, FormatDate, SetStatus, ToggleHostEnabled, HostsEdit, Find, ShowJobSummary) {
ClearScope('htmlTemplate'); //Garbage collection. Don't leave behind any listeners/watchers from the prior
//scope.
@ -647,10 +588,6 @@ export function HomeHosts($scope, $location, $stateParams, HomeHostList, Generat
$scope.search(list.iterator);
};
$scope.viewJob = function(id) {
ViewJob({ scope: $scope, id: id });
};
$scope.toggleHostEnabled = function (id, sources) {
ToggleHostEnabled({
host_id: id,
@ -687,5 +624,5 @@ export function HomeHosts($scope, $location, $stateParams, HomeHostList, Generat
HomeHosts.$inject = ['$scope', '$location', '$stateParams', 'HomeHostList', 'generateList', 'ProcessErrors', 'ReturnToCaller',
'ClearScope', 'GetBasePath', 'SearchInit', 'PaginateInit', 'FormatDate', 'SetStatus', 'ToggleHostEnabled', 'HostsEdit',
'Find', 'ShowJobSummary', 'ViewJob'
'Find', 'ShowJobSummary'
];

View File

@ -16,7 +16,7 @@ export function InventoriesList($scope, $rootScope, $location, $log,
$stateParams, $compile, $filter, sanitizeFilter, Rest, Alert, InventoryList,
generateList, Prompt, SearchInit, PaginateInit, ReturnToCaller,
ClearScope, ProcessErrors, GetBasePath, Wait,
EditInventoryProperties, Find, Empty, LogViewer, $state) {
EditInventoryProperties, Find, Empty, $state) {
var list = InventoryList,
defaultUrl = GetBasePath('inventory'),
@ -295,10 +295,12 @@ export function InventoriesList($scope, $rootScope, $location, $log,
};
$scope.viewJob = function(url) {
LogViewer({
scope: $scope,
url: url
});
// Pull the id out of the URL
var id = url.replace(/^\//, '').split('/')[3];
$state.go('inventorySyncStdout', {id: id});
};
$scope.editInventoryProperties = function (inventory_id) {
@ -364,7 +366,7 @@ export function InventoriesList($scope, $rootScope, $location, $log,
InventoriesList.$inject = ['$scope', '$rootScope', '$location', '$log', '$stateParams', '$compile', '$filter', 'sanitizeFilter', 'Rest', 'Alert', 'InventoryList', 'generateList',
'Prompt', 'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope', 'ProcessErrors',
'GetBasePath', 'Wait', 'EditInventoryProperties', 'Find', 'Empty', 'LogViewer', '$state'
'GetBasePath', 'Wait', 'EditInventoryProperties', 'Find', 'Empty', '$state'
];
@ -781,7 +783,7 @@ export function InventoriesManage ($log, $scope, $rootScope, $location,
GetHostsStatusMsg, GroupsEdit, InventoryUpdate, GroupsCancelUpdate,
ViewUpdateStatus, GroupsDelete, Store, HostsEdit, HostsDelete,
EditInventoryProperties, ToggleHostEnabled, ShowJobSummary,
InventoryGroupsHelp, HelpDialog, ViewJob,
InventoryGroupsHelp, HelpDialog,
GroupsCopy, HostsCopy, $stateParams) {
var PreviousSearchParams,
@ -920,7 +922,7 @@ export function InventoriesManage ($log, $scope, $rootScope, $location,
generateList.inject(InventoryGroups, {
mode: 'edit',
id: 'group-list-container',
searchSize: 'col-lg-6 col-md-6 col-sm-6',
searchSize: 'col-lg-6 col-md-6 col-sm-6 col-xs-12',
scope: $scope
});
@ -1254,12 +1256,8 @@ export function InventoriesManage ($log, $scope, $rootScope, $location,
opts.autoShow = params.autoShow || false;
}
HelpDialog(opts);
};
$scope.viewJob = function(id) {
ViewJob({ scope: $scope, id: id });
};
}
;
$scope.showHosts = function (group_id, show_failures) {
// Clicked on group
if (group_id !== null) {
@ -1293,6 +1291,6 @@ InventoriesManage.$inject = ['$log', '$scope', '$rootScope', '$location',
'GroupsEdit', 'InventoryUpdate', 'GroupsCancelUpdate', 'ViewUpdateStatus',
'GroupsDelete', 'Store', 'HostsEdit', 'HostsDelete',
'EditInventoryProperties', 'ToggleHostEnabled', 'ShowJobSummary',
'InventoryGroupsHelp', 'HelpDialog', 'ViewJob', 'GroupsCopy',
'InventoryGroupsHelp', 'HelpDialog', 'GroupsCopy',
'HostsCopy', '$stateParams'
];

View File

@ -1,189 +0,0 @@
/************************************
* Copyright (c) 2015 Ansible, Inc.
*
*
* Organizations.js
*
* Controller functions for Organization model.
*
*/
/**
* @ngdoc function
* @name controllers.function:Organizations
* @description This controller's for the Organizations page
*/
export function LicenseController(ClearScope, $location, $rootScope, $compile, $filter, GenerateForm, Rest, Alert,
GetBasePath, ProcessErrors, FormatDate, Prompt, Empty, LicenseForm, IsAdmin, CreateDialog, CheckLicense,
TextareaResize, $scope, Wait) {
ClearScope();
$scope.getDefaultHTML = function(license_info) {
var fld, html,
self = this,
generator = GenerateForm;
self.form = angular.copy(LicenseForm);
for (fld in self.form.fields) {
if (fld !== 'time_remaining' && fld !== 'license_status' && fld !== 'tower_version') {
if (Empty(license_info[fld])) {
delete self.form.fields[fld];
}
}
}
if (!IsAdmin()) {
delete self.form.fields.license_key;
}
if (license_info.is_aws || Empty(license_info.license_date)) {
delete self.form.fields.license_date;
delete self.form.fields.time_remaining;
}
html = generator.buildHTML(self.form, { mode: 'edit', showButtons: false });
return html;
};
$scope.loadDefaultScope = function(license_info, version) {
var fld, dt, days, license,
self = this;
for (fld in self.form.fields) {
if (!Empty(license_info[fld])) {
$scope[fld] = license_info[fld];
}
}
$scope.tower_version = version;
if ($scope.license_date) {
dt = new Date(parseInt($scope.license_date, 10) * 1000); // expects license_date in seconds
$scope.license_date = FormatDate(dt);
$scope.time_remaining = parseInt($scope.time_remaining,10) * 1000;
if ($scope.time_remaining < 0) {
days = 0;
} else {
days = Math.floor($scope.time_remaining / 86400000);
}
$scope.time_remaining = (days!==1) ? $filter('number')(days, 0) + ' days' : $filter('number')(days, 0) + ' day'; // '1 day' and '0 days/2 days' or more
}
if (parseInt($scope.free_instances) <= 0) {
$scope.free_instances_class = 'field-failure';
} else {
$scope.free_instances_class = 'field-success';
}
license = license_info;
if (license.valid_key === undefined) {
$scope.license_status = 'Missing License Key';
$scope.status_color = 'license-invalid';
} else if (!license.valid_key) {
$scope.license_status = 'Invalid License Key';
$scope.status_color = 'license-invalid';
} else if (license.date_expired !== undefined && license.date_expired) {
$scope.license_status = 'License Expired';
$scope.status_color = 'license-expired';
} else if (license.date_warning !== undefined && license.date_warning) {
$scope.license_status = 'License Expiring Soon';
$scope.status_color = 'license-warning';
} else if (license.free_instances !== undefined && parseInt(license.free_instances) <= 0) {
$scope.license_status = 'No Available Managed Hosts';
$scope.status_color = 'license-invalid';
} else {
$scope.license_status = 'Valid License';
$scope.status_color = 'license-valid';
}
};
$scope.setLicense = function(license_info, version) {
this.license = license_info;
this.version = version;
};
$scope.getLicense = function(){
return this.license;
};
$scope.submitLicenseKey = function() {
CheckLicense.postLicense($scope.license_json, $scope);
};
if ($scope.removeLicenseDataReady) {
$scope.removeLicenseDataReady();
}
$scope.removeLicenseDataReady = $scope.$on('LicenseDataReady', function(e, data) {
var html, version, eula, h;
version = data.version.replace(/-.*$/,'');
$scope.setLicense(data.license_info, version);
html = $scope.getDefaultHTML(data.license_info);
$scope.loadDefaultScope(data.license_info, version);
eula = (data.eula) ? data.eula : "" ;
e = angular.element(document.getElementById('license-modal-dialog'));
e.empty().html(html);
$scope.parseType = 'json';
$scope.license_json = JSON.stringify($scope.license, null, ' ');
$scope.eula = eula;
$scope.eula_agreement = false;
h = CheckLicense.getHTML($scope.getLicense(),true).body;
$('#license-modal-dialog #license_tabs').append("<li><a id=\"update_license_link\" ng-click=\"toggleTab($event, 'update_license_link', 'license_tabs')\" href=\"#update_license\" data-toggle=\"tab\">Update License</a></li>");
$('#license-modal-dialog .tab-content').append("<div class=\"tab-pane\" id=\"update_license\"></div>");
$('#license-modal-dialog #update_license').html(h);
if ($scope.license_status === 'Invalid License Key' || $scope.license_status === 'Missing License Key') {
$('#license_tabs li:eq(1)').hide();
$('#license_tabs li:eq(2) a').tab('show');
}
$('#license_license_json').attr('ng-required' , 'true' );
$('#license_eula_agreement_chbox').attr('ng-required' , 'true' );
$('#license_form_submit_btn').attr('ng-disabled' , "license_form.$invalid" );
e = angular.element(document.getElementById('license-modal-dialog'));
$compile(e)($scope);
if (IsAdmin()) {
setTimeout(function() {
TextareaResize({
scope: $scope,
textareaId: 'license_license_json',
modalId: 'license-modal-dialog',
formId: 'license-notification-body',
fld: 'license_json',
parse: true,
bottom_margin: 90,
onChange: function() { $scope.license_json_api_error = ''; }
});
}, 300);
}
$('#license-ok-button').focus();
$('#update_license_link').on('shown.bs.tab', function() {
if (IsAdmin()) {
TextareaResize({
scope: $scope,
textareaId: 'license_license_json',
modalId: 'license-modal-dialog',
formId: 'license-notification-body',
fld: 'license_json',
bottom_margin: 90,
parse: true,
onChange: function() { $scope.license_json_api_error = ''; }
});
}
});
Wait("stop");
});
CheckLicense.GetLicense('LicenseDataReady', $scope);
}
LicenseController.$inject = ['ClearScope', '$location', '$rootScope', '$compile', '$filter', 'GenerateForm', 'Rest', 'Alert',
'GetBasePath', 'ProcessErrors', 'FormatDate', 'Prompt', 'Empty', 'LicenseForm', 'IsAdmin', 'CreateDialog',
'CheckLicense', 'TextareaResize', '$scope', "Wait"];

View File

@ -15,7 +15,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams,
Rest, Alert, ProjectList, GenerateList, Prompt, SearchInit,
PaginateInit, ReturnToCaller, ClearScope, ProcessErrors, GetBasePath,
SelectionInit, ProjectUpdate, Refresh, Wait, GetChoices, Empty,
Find, LogViewer, GetProjectIcon, GetProjectToolTip, $filter, $state) {
Find, GetProjectIcon, GetProjectToolTip, $filter, $state) {
ClearScope();
@ -200,24 +200,19 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams,
$state.transitionTo('projects.edit', {id: id});
};
if ($scope.removeShowLogViewer) {
$scope.removeShowLogViewer();
if ($scope.removeGoToJobDetails) {
$scope.removeGoToJobDetails();
}
$scope.removeShowLogViewer = $scope.$on('ShowLogViewer', function(e, data) {
if (data.related.current_update) {
$scope.removeGoToJobDetails = $scope.$on('GoToJobDetails', function(e, data) {
if (data.summary_fields.current_update || data.summary_fields.last_update) {
Wait('start');
LogViewer({
scope: $scope,
url: data.related.current_update,
getIcon: GetProjectIcon
});
} else if (data.related.last_update) {
Wait('start');
LogViewer({
scope: $scope,
url: data.related.last_update,
getIcon: GetProjectIcon
});
// Grab the id from summary_fields
var id = (data.summary_fields.current_update) ? data.summary_fields.current_update.id : data.summary_fields.last_update.id;
$state.go('scmUpdateStdout', {id: id});
} else {
Alert('No Updates Available', 'There is no SCM update information available for this project. An update has not yet been ' +
' completed. If you have not already done so, start an update for this project.', 'alert-info');
@ -235,7 +230,7 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams,
Rest.setUrl(project.url);
Rest.get()
.success(function(data) {
$scope.$emit('ShowLogViewer', data);
$scope.$emit('GoToJobDetails', data);
})
.error(function(data, status) {
ProcessErrors($scope, data, status, null, { hdr: 'Error!',
@ -374,7 +369,7 @@ ProjectsList.$inject = ['$scope', '$rootScope', '$location', '$log',
'SearchInit', 'PaginateInit', 'ReturnToCaller', 'ClearScope',
'ProcessErrors', 'GetBasePath', 'SelectionInit', 'ProjectUpdate',
'Refresh', 'Wait', 'GetChoices', 'Empty', 'Find',
'LogViewer', 'GetProjectIcon', 'GetProjectToolTip', '$filter', '$state'
'GetProjectIcon', 'GetProjectToolTip', '$filter', '$state'
];

View File

@ -6,7 +6,7 @@
color: #848992;
width: 100%;
z-index: 1040;
position: absolute;
position: fixed;
right: 0;
left: 0;
bottom: 0;

View File

@ -7,7 +7,6 @@
import './forms';
import './lists';
import AboutAnsible from "./helpers/AboutAnsible";
import Children from "./helpers/Children";
import Credentials from "./helpers/Credentials";
import EventViewer from "./helpers/EventViewer";
@ -19,9 +18,7 @@ import JobDetail from "./helpers/JobDetail";
import JobSubmission from "./helpers/JobSubmission";
import JobTemplates from "./helpers/JobTemplates";
import Jobs from "./helpers/Jobs";
import License from "./helpers/License";
import LoadConfig from "./helpers/LoadConfig";
import LogViewer from "./helpers/LogViewer";
import PaginationHelpers from "./helpers/PaginationHelpers";
import Parse from "./helpers/Parse";
import ProjectPath from "./helpers/ProjectPath";
@ -44,8 +41,7 @@ import ApiModelHelper from "./helpers/ApiModel";
import ActivityStreamHelper from "./helpers/ActivityStream";
export
{ AboutAnsible,
Children,
{ Children,
Credentials,
EventViewer,
Events,
@ -56,9 +52,7 @@ export
JobSubmission,
JobTemplates,
Jobs,
License,
LoadConfig,
LogViewer,
PaginationHelpers,
Parse,
ProjectPath,

View File

@ -1,93 +0,0 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc overview
* @name helpers
* @description These are helpers...figure it out :)
*/
/**
* @ngdoc function
* @name helpers.function:AboutAnsible
* @description This is the code for the About Ansible modal window that pops up with cowsay giving company/tower info and copyright information.
*/
export default
angular.module('AboutAnsibleHelpModal', ['RestServices', 'Utilities','ModalDialog'])
.factory('AboutAnsibleHelp', ['$rootScope', '$compile', '$location' , 'Rest', 'GetBasePath', 'ProcessErrors', 'Wait', 'CreateDialog',
function ($rootScope, $compile , $location, Rest, GetBasePath, ProcessErrors, Wait, CreateDialog) {
return function () {
var scope= $rootScope.$new(),
url;
url = GetBasePath('config');
Rest.setUrl(url);
Rest.get()
.success(function (data){
scope.$emit('BuildAboutDialog', data);
})
.error(function (data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to get: ' + url + ' GET returned: ' + status });
});
if (scope.removeDialogReady) {
scope.removeDialogReady();
}
scope.removeDialogReady = scope.$on('DialogReady', function() {
// element = angular.element(document.getElementById('about-modal-dialog'));
// $compile(element)(scope);
$('#about-modal-dialog').dialog('open');
});
if (scope.removeBuildAboutDialog) {
scope.removeBuildAboutDialog();
}
scope.removeBuildAboutDialog = scope.$on('BuildAboutDialog', function(e, data) {
var spaces, i, j,
paddedStr = "",
versionParts,
str = data.version,
subscription = data.license_info.subscription_name || "";
versionParts = str.split('-');
spaces = Math.floor((16-versionParts[0].length)/2);
for( i=0; i<=spaces; i++){
paddedStr = paddedStr +" ";
}
paddedStr = paddedStr + versionParts[0];
for( j = paddedStr.length; j<16; j++){
paddedStr = paddedStr + " ";
}
$('#about-modal-version').html(paddedStr);
$('#about-modal-subscription').html(subscription);
scope.modalOK = function(){
$('#about-modal-dialog').dialog('close');
};
CreateDialog({
id: 'about-modal-dialog',
scope: scope,
// buttons: [],
width: 710,
height: 450,
minWidth: 300,
resizable: false,
callback: 'DialogReady',
onOpen: function(){
$('#dialog-ok-button').focus();
$('#about-modal-dialog').scrollTop(0);
$('#about-modal-dialog').css('overflow-x', 'hidden');
$('.ui-widget-overlay').css('width', '100%');
}
});
});
};
}
]);

View File

@ -16,7 +16,7 @@ export default
function () {
return function (target) {
var rtnTitle = 'DASHBOARD';
var rtnTitle = 'ALL ACTIVITY';
switch(target) {
case 'project':
@ -49,6 +49,9 @@ export default
case 'schedule':
rtnTitle = 'SCHEDULES';
break;
case 'host':
rtnTitle = 'HOSTS';
break;
}
return rtnTitle;

View File

@ -13,8 +13,8 @@
export default
angular.module('EventViewerHelper', ['ModalDialog', 'Utilities', 'EventsViewerFormDefinition', 'HostsHelper'])
.factory('EventViewer', ['$compile', 'CreateDialog', 'GetEvent', 'Wait', 'EventAddTable', 'GetBasePath', 'LookUpName', 'Empty', 'EventAddPreFormattedText',
function($compile, CreateDialog, GetEvent, Wait, EventAddTable, GetBasePath, LookUpName, Empty, EventAddPreFormattedText) {
.factory('EventViewer', ['$compile', 'CreateDialog', 'GetEvent', 'Wait', 'EventAddTable', 'GetBasePath', 'Empty', 'EventAddPreFormattedText',
function($compile, CreateDialog, GetEvent, Wait, EventAddTable, GetBasePath, Empty, EventAddPreFormattedText) {
return function(params) {
var parent_scope = params.scope,
url = params.url,

View File

@ -18,7 +18,7 @@ export default
angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name, 'GroupListDefinition', 'SearchHelper',
'PaginationHelpers', listGenerator.name, 'GroupsHelper', 'InventoryHelper', 'SelectionHelper',
'JobSubmissionHelper', 'RefreshHelper', 'PromptDialog', 'CredentialsListDefinition', 'InventoryTree',
'InventoryStatusDefinition', 'VariablesHelper', 'SchedulesListDefinition', 'SourceFormDefinition', 'LogViewerHelper',
'InventoryStatusDefinition', 'VariablesHelper', 'SchedulesListDefinition', 'SourceFormDefinition', 'StandardOutHelper',
'SchedulesHelper'
])
@ -65,8 +65,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
* TODO: Document
*
*/
.factory('ViewUpdateStatus', ['Rest', 'ProcessErrors', 'GetBasePath', 'Alert', 'Wait', 'Empty', 'Find', 'LogViewer',
function (Rest, ProcessErrors, GetBasePath, Alert, Wait, Empty, Find, LogViewer) {
.factory('ViewUpdateStatus', ['$state', 'Rest', 'ProcessErrors', 'GetBasePath', 'Alert', 'Wait', 'Empty', 'Find',
function ($state, Rest, ProcessErrors, GetBasePath, Alert, Wait, Empty, Find) {
return function (params) {
var scope = params.scope,
@ -76,11 +76,13 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
if (scope.removeSourceReady) {
scope.removeSourceReady();
}
scope.removeSourceReady = scope.$on('SourceReady', function(e, url) {
LogViewer({
scope: scope,
url: url
});
scope.removeSourceReady = scope.$on('SourceReady', function(e, source) {
// Get the ID from the correct summary field
var update_id = (source.current_update) ? source.summary_fields.current_update.id : source.summary_fields.last_update.id;
$state.go('inventorySyncStdout', {id: update_id});
});
if (group) {
@ -94,8 +96,7 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
Rest.setUrl(group.related.inventory_source);
Rest.get()
.success(function (data) {
var url = (data.related.current_update) ? data.related.current_update : data.related.last_update;
scope.$emit('SourceReady', url);
scope.$emit('SourceReady', data);
})
.error(function (data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',

View File

@ -20,7 +20,7 @@ export default
angular.module('HostsHelper', [ 'RestServices', 'Utilities', listGenerator.name, 'HostListDefinition',
'SearchHelper', 'PaginationHelpers', listGenerator.name, 'HostsHelper',
'InventoryHelper', 'RelatedSearchHelper', 'InventoryFormDefinition', 'SelectionHelper',
'HostGroupsFormDefinition', 'VariablesHelper', 'ModalDialog', 'LogViewerHelper',
'HostGroupsFormDefinition', 'VariablesHelper', 'ModalDialog', 'StandardOutHelper',
'GroupListDefinition'
])
@ -159,17 +159,6 @@ angular.module('HostsHelper', [ 'RestServices', 'Utilities', listGenerator.name,
};
}])
.factory('ViewJob', ['LogViewer', 'GetBasePath', function(LogViewer, GetBasePath) {
return function(params) {
var scope = params.scope,
id = params.id;
LogViewer({
scope: scope,
url: GetBasePath('jobs') + id + '/'
});
};
}])
.factory('HostsReload', [ '$stateParams', 'Empty', 'InventoryHosts', 'GetBasePath', 'SearchInit', 'PaginateInit', 'Wait',
'SetHostStatus', 'SetStatus', 'ApplyEllipsis',
function($stateParams, Empty, InventoryHosts, GetBasePath, SearchInit, PaginateInit, Wait, SetHostStatus, SetStatus,
@ -240,7 +229,7 @@ angular.module('HostsHelper', [ 'RestServices', 'Utilities', listGenerator.name,
generator = GenerateList;
// Inject the list html
generator.inject(InventoryHosts, { scope: host_scope, mode: 'edit', id: 'host-list-container', searchSize: 'col-lg-6 col-md-6 col-sm-6' });
generator.inject(InventoryHosts, { scope: host_scope, mode: 'edit', id: 'host-list-container', searchSize: 'col-lg-6 col-md-6 col-sm-6 col-xs-12' });
// Load data
HostsReload({ scope: host_scope, group_id: group_id, inventory_id: inventory_id, parent_scope: group_scope, pageSize: pageSize });

View File

@ -235,7 +235,7 @@ export default
}
if (newActivePlay) {
scope.activePlay = newActivePlay;
scope.jobData.plays[scope.activePlay].playActiveClass = 'active';
scope.jobData.plays[scope.activePlay].playActiveClass = 'JobDetail-tableRow--selected';
}
}
};
@ -265,7 +265,7 @@ export default
}
if (newActiveTask) {
scope.activeTask = newActiveTask;
scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'active';
scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'JobDetail-tableRow--selected';
}
}
};
@ -700,12 +700,12 @@ export default
task.missingPct = task.missingPct - diff;
}
}
task.successfulStyle = (task.successfulPct > 0) ? { 'display': 'inline-block', 'width': task.successfulPct + "%" } : { 'display': 'none' };
task.changedStyle = (task.changedPct > 0) ? { 'display': 'inline-block', 'width': task.changedPct + "%" } : { 'display': 'none' };
task.skippedStyle = (task.skippedPct > 0) ? { 'display': 'inline-block', 'width': task.skippedPct + "%" } : { 'display': 'none' };
task.failedStyle = (task.failedPct > 0) ? { 'display': 'inline-block', 'width': task.failedPct + "%" } : { 'display': 'none' };
task.unreachableStyle = (task.unreachablePct > 0) ? { 'display': 'inline-block', 'width': task.unreachablePct + "%" } : { 'display': 'none' };
task.missingStyle = (task.missingPct > 0) ? { 'display': 'inline-block', 'width': task.missingPct + "%" } : { 'display': 'none' };
task.successfulStyle = (task.successfulPct > 0) ? { 'display': 'inline-block' }: { 'display': 'none' };
task.changedStyle = (task.changedPct > 0) ? { 'display': 'inline-block'} : { 'display': 'none' };
task.skippedStyle = (task.skippedPct > 0) ? { 'display': 'inline-block' } : { 'display': 'none' };
task.failedStyle = (task.failedPct > 0) ? { 'display': 'inline-block' } : { 'display': 'none' };
task.unreachableStyle = (task.unreachablePct > 0) ? { 'display': 'inline-block' } : { 'display': 'none' };
task.missingStyle = (task.missingPct > 0) ? { 'display': 'inline-block' } : { 'display': 'none' };
};
}])
@ -793,7 +793,7 @@ export default
scope.selectedPlay = id;
scope.plays.forEach(function(play, idx) {
if (play.id === scope.selectedPlay) {
scope.plays[idx].playActiveClass = 'active';
scope.plays[idx].playActiveClass = 'JobDetail-tableRow--selected';
}
else {
scope.plays[idx].playActiveClass = '';
@ -940,7 +940,7 @@ export default
scope.selectedTask = id;
scope.tasks.forEach(function(task, idx) {
if (task.id === scope.selectedTask) {
scope.tasks[idx].taskActiveClass = 'active';
scope.tasks[idx].taskActiveClass = 'JobDetail-tableRow--selected';
}
else {
scope.tasks[idx].taskActiveClass = '';
@ -1142,8 +1142,7 @@ export default
.factory('DrawGraph', ['DonutChart', function(DonutChart) {
return function(params) {
var scope = params.scope,
resize = params.resize,
width, height, svg_height, svg_width, svg_radius, graph_data = [];
graph_data = [];
// Ready the data
if (scope.host_summary.ok) {
@ -1155,21 +1154,21 @@ export default
}
if (scope.host_summary.changed) {
graph_data.push({
label: 'Changed',
label: 'CHANGED',
value: scope.host_summary.changed,
color: '#FF9900'
});
}
if (scope.host_summary.unreachable) {
graph_data.push({
label: 'Unreachable',
label: 'UNREACHABLE',
value: scope.host_summary.unreachable,
color: '#FF0000'
});
}
if (scope.host_summary.failed) {
graph_data.push({
label: 'Failed',
label: 'FAILED',
value: scope.host_summary.failed,
color: '#ff5850'
});
@ -1180,148 +1179,91 @@ export default
total_count += graph_data[gd_obj].value;
}
scope.total_count_for_graph = total_count;
// Adjust the size
width = $('#job-summary-container .job_well').width();
height = $('#job-summary-container .job_well').height() - $('#summary-well-top-section').height() - $('#graph-section .header').outerHeight() - 80;
svg_radius = Math.min(width, height);
svg_width = width;
svg_height = height;
if (svg_height > 0 && svg_width > 0) {
if (!resize && $('#graph-section svg').length > 0) {
// Donut3D.transition("completedHostsDonut", graph_data, Math.floor(svg_radius * 0.50), Math.floor(svg_radius * 0.25), 18, 0.4);
DonutChart({
target: '#graph-section',
height: height,
width: width,
data: graph_data,
radius: svg_radius
});
}
else {
if ($('#graph-section svg').length > 0) {
$('#graph-section svg').remove();
}
// svg = d3.select("#graph-section").append("svg").attr("width", svg_width).attr("height", svg_height);
// svg.append("g").attr("id","completedHostsDonut");
// Donut3D.draw("completedHostsDonut", graph_data, Math.floor(svg_width / 2), Math.floor(svg_height / 2) - 35, Math.floor(svg_radius * 0.50), Math.floor(svg_radius * 0.25), 18, 0.4);
DonutChart({
target: '#graph-section',
height: height,
width: width,
data: graph_data,
radius: svg_radius
});
$('#graph-section .header .legend').show();
}
}
DonutChart({
data: graph_data
});
};
}])
.factory('DonutChart', [function() {
return function(params) {
var target = params.target,
height = Math.max(params.height, 250),
width = Math.max(params.width, 250),
dataset = params.data,
outerRadius = Math.min(width, height) / 2,
innerRadius = (outerRadius/3),
svg, arc, pie, legend,
tooltip, path,
legendRectSize = 18,
legendSpacing = 4;
var dataset = params.data,
element = $("#graph-section"),
colors, total,job_detail_chart;
svg = d3.select(target)
.append('svg')
.data([dataset])
.attr('width', width)
.attr('height', height)
.append('g')
.attr('transform', 'translate(' + (width / 2) +
',' + (height / 2) + ')');
colors = _.map(dataset, function(d){
return d.color;
});
total = d3.sum(dataset.map(function(d) {
return d.value;
}));
job_detail_chart = nv.models.pieChart()
.margin({bottom: 15})
.x(function(d) {
return d.label +': '+ Math.round((d.value/total)*100) + "%";
})
.y(function(d) { return d.value; })
.showLabels(true)
.showLegend(false)
.growOnHover(false)
.labelThreshold(0.01)
.tooltipContent(function(x, y) {
return '<p>'+x+'</p>'+ '<p>' + Math.floor(y.replace(',','')) + ' HOSTS ' + '</p>';
})
.color(colors);
arc = d3.svg.arc()
.innerRadius(outerRadius - innerRadius)
.outerRadius(outerRadius);
pie = d3.layout.pie()
.value(function(d) { return d.value; })
.sort(function() {return null; });
tooltip = d3.select(target)
.append('div')
.attr('class', 'donut-tooltip');
tooltip.append('div')
.attr('class', 'donut-tooltip-inner');
path = svg.selectAll('path')
.data(pie(dataset))
.enter()
.append('path')
.attr('d', arc)
.attr('fill', function(d) {
return d.data.color;
d3.select(element.find('svg')[0])
.datum(dataset)
.transition().duration(350)
.call(job_detail_chart)
.style({
"font-family": 'Open Sans',
"font-style": "normal",
"font-weight":400,
"src": "url(/static/assets/OpenSans-Regular.ttf)"
});
path.on('mouseenter', function(d) {
var total = d3.sum(dataset.map(function(d) {
return d.value;
}));
var label;
if (d.data.value === 1) {
label = " host ";
} else {
label = " hosts ";
}
var percent = Math.round(1000 * d.data.value / total) / 10;
tooltip.select('.donut-tooltip-inner').html(d.data.value + label + " (" +
percent + "%) " + d.data.label + ".");
//.attr('style', 'color:white;font-family:');
tooltip.style('display', 'block');
});
path.on('mouseleave', function() {
tooltip.style('display', 'none');
});
path.on('mousemove', function() {
// d3.mouse() gives the coordinates of hte mouse, then add
// some offset to provide breathing room for hte tooltip
// based on the dimensions of the donut
tooltip.style('top', (d3.mouse(this)[1] + (height/5) + 'px'))
.style('left', (d3.mouse(this)[0] + (width/3) + 'px'));
});
legend = svg.selectAll('.legend')
.data(pie(dataset))
.enter()
.append('g')
.attr('class', 'legend')
.attr('transform', function(d, i) {
var height = legendRectSize + legendSpacing;
var offset = height * dataset.length / 2;
var horz = -2 * legendRectSize;
var vert = i * height - offset;
return 'translate(' + horz + ',' + vert + ')';
});
legend.append('rect')
.attr('width', legendRectSize)
.attr('height', legendRectSize)
.attr('fill', function(d) {
return d.data.color;
})
.attr('stroke', function(d) {
return d.data.color;
});
legend.append('text')
.attr('x', legendRectSize + legendSpacing)
.attr('y', legendRectSize - legendSpacing)
.text(function(d) {
return d.data.label;
});
d3.select(element.find(".nv-label text")[0])
.attr("class", "DashboardGraphs-hostStatusLabel--successful")
.style({
"font-family": 'Open Sans',
"text-anchor": "start",
"font-size": "16px",
"text-transform" : "uppercase",
"fill" : colors[0],
"src": "url(/static/assets/OpenSans-Regular.ttf)"
});
d3.select(element.find(".nv-label text")[1])
.attr("class", "DashboardGraphs-hostStatusLabel--failed")
.style({
"font-family": 'Open Sans',
"text-anchor" : "end !imporant",
"font-size": "16px",
"text-transform" : "uppercase",
"fill" : colors[1],
"src": "url(/static/assets/OpenSans-Regular.ttf)"
});
d3.select(element.find(".nv-label text")[2])
.attr("class", "DashboardGraphs-hostStatusLabel--successful")
.style({
"font-family": 'Open Sans',
"text-anchor" : "end !imporant",
"font-size": "16px",
"text-transform" : "uppercase",
"fill" : colors[2],
"src": "url(/static/assets/OpenSans-Regular.ttf)"
});
d3.select(element.find(".nv-label text")[3])
.attr("class", "DashboardGraphs-hostStatusLabel--failed")
.style({
"font-family": 'Open Sans',
"text-anchor" : "end !imporant",
"font-size": "16px",
"text-transform" : "uppercase",
"fill" : colors[3],
"src": "url(/static/assets/OpenSans-Regular.ttf)"
});
return job_detail_chart;
};
}])

View File

@ -14,7 +14,7 @@ import listGenerator from '../shared/list-generator/main';
export default
angular.module('JobsHelper', ['Utilities', 'RestServices', 'FormGenerator', 'JobSummaryDefinition', 'InventoryHelper', 'GeneratorHelpers',
'JobSubmissionHelper', 'LogViewerHelper', 'SearchHelper', 'PaginationHelpers', 'AdhocHelper', listGenerator.name])
'JobSubmissionHelper', 'StandardOutHelper', 'SearchHelper', 'PaginationHelpers', 'AdhocHelper', listGenerator.name])
/**
* JobsControllerInit({ scope: $scope });
@ -22,8 +22,8 @@ export default
* Initialize calling scope with all the bits required to support a jobs list
*
*/
.factory('JobsControllerInit', ['$state', 'Find', 'DeleteJob', 'RelaunchJob', 'LogViewer', '$window',
function($state, Find, DeleteJob, RelaunchJob, LogViewer, $window) {
.factory('JobsControllerInit', ['$state', 'Find', 'DeleteJob', 'RelaunchJob', '$window',
function($state, Find, DeleteJob, RelaunchJob, $window) {
return function(params) {
var scope = params.scope,
iterator = (params.iterator) ? params.iterator : scope.iterator;

View File

@ -1,271 +0,0 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name helpers.function:License
* @description Routines for checking and reporting license status
* CheckLicense.test() is called in app.js, in line 532, which is when the license is checked. The license information is
* stored in local storage using 'Store()'.
*
*
*
*
*/
import '../forms';
export default
angular.module('LicenseHelper', ['RestServices', 'Utilities', 'LicenseUpdateFormDefinition',
'FormGenerator', 'ParseHelper', 'ModalDialog', 'VariablesHelper', 'LicenseFormDefinition'])
.factory('CheckLicense', ['$q', '$rootScope', '$compile', 'CreateDialog', 'Store',
'LicenseUpdateForm', 'GenerateForm', 'TextareaResize', 'ToJSON', 'GetBasePath',
'Rest', 'ProcessErrors', 'Alert', 'IsAdmin', '$location', 'pendoService',
'Authorization', 'Wait',
function($q, $rootScope, $compile, CreateDialog, Store, LicenseUpdateForm, GenerateForm,
TextareaResize, ToJSON, GetBasePath, Rest, ProcessErrors, Alert, IsAdmin, $location,
pendoService, Authorization, Wait) {
return {
getRemainingDays: function(time_remaining) {
// assumes time_remaining will be in seconds
var tr = parseInt(time_remaining, 10);
return Math.floor(tr / 86400);
},
shouldNotify: function(license) {
if (license && typeof license === 'object' && Object.keys(license).length > 0) {
// we have a license object
if (!license.valid_key) {
// missing valid key
return true;
}
else if (license.free_instances <= 0) {
// host count exceeded
return true;
}
else if (this.getRemainingDays(license.time_remaining) < 15) {
// below 15 days remaining on license
return true;
}
return false;
} else {
// missing license object
return true;
}
},
isAdmin: function() {
return IsAdmin();
},
getHTML: function(license, includeFormButton) {
var title, html,
contact_us = "<a href=\"http://www.ansible.com/contact-us\" target=\"_black\">contact us <i class=\"fa fa-external-link\"></i></a>",
renew = "<a href=\"http://www.ansible.com/renew\" target=\"_blank\">ansible.com/renew <i class=\"fa fa-external-link\"></i></a>",
pricing = "<a href=\"http://www.ansible.com/pricing\" target=\"_blank\">ansible.com/pricing <i class=\"fa fa-external-link\"></i></a>",
license_link = "<a href=\"http://www.ansible.com/license\" target=\"_blank\">click here</a>",
result = {},
license_is_valid=false;
if (license && typeof license === 'object' && Object.keys(license).length > 0 && license.valid_key !== undefined) {
// we have a license
if (!license.valid_key) {
title = "Invalid License";
html = "<div id=\"license-notification-body\"><div style=\"margin-top:5px; margin-bottom:25px;\"><p>The Ansible Tower license is invalid.</p>";
}
else if (this.getRemainingDays(license.time_remaining) <= 0) {
title = "License Expired";
html = "<div id=\"license-notification-body\"><div style=\"margin-top:5px; margin-bottom:25px;\">\n" +
"<p>Thank you for using Ansible Tower. The Ansible Tower license has expired</p>";
if (parseInt(license.grace_period_remaining,10) > 86400) {
// trial licenses don't get a grace period
if (license.trial) {
html += "<p>Don't worry &mdash; your existing history and content has not been affected, but playbooks will no longer run and new hosts cannot be added. " +
"If you are ready to upgrade, " + contact_us + " or visit " + pricing + " to see all of your license options. Thanks!</p>";
} else {
html += "<p>Don't worry &mdash; your existing history and content has not been affected, but in " + this.getRemainingDays(license.grace_period_remaining) + " days playbooks will no longer " +
"run and new hosts cannot be added. If you are ready to upgrade, " + contact_us + " " +
"or visit <a href=\"http://www.ansible.com/pricing\" target=\"_blank\">ansible.com/pricing <i class=\"fa fa-external-link\"></i></a> to see all of your license options. Thanks!</p>";
}
} else {
html += "<p>Dont worry &mdash; your existing history and content has not been affected, but playbooks will no longer run and new hosts cannot be added. If you are ready to renew or upgrade, contact us " +
"at " + renew + ". Thanks!</p>";
}
}
else if (this.getRemainingDays(license.time_remaining) < 15) {
// Warning: license expiring in less than 15 days
title = "License Warning";
html = "<div id=\"license-notification-body\"><div style=\"margin-top:5px; margin-bottom:25px;\"><p>Thank you for using Ansible Tower. The Ansible Tower license " +
"has " + this.getRemainingDays(license.time_remaining) + " days remaining.</p>";
// trial licenses don't get a grace period
if (license.trial) {
html += "<p>After this license expires, playbooks will no longer run and hosts cannot be added. If you are ready to upgrade, " + contact_us + " or visit " + pricing + " to see all of your license options. Thanks!</p>";
} else {
html += "<p>After this license expires, playbooks will no longer run and hosts cannot be added. If you are ready to renew or upgrade, contact us at " + renew + ". Thanks!</p>";
}
// If there is exactly one day remaining, change "days remaining"
// to "day remaining".
html = html.replace('has 1 days remaining', 'has 1 day remaining');
}
else if (license.free_instances <= 0) {
title = "Host Count Exceeded";
html = "<div id=\"license-notification-body\"><div style=\"margin-top:5px; margin-bottom:25px;\"><p>The Ansible Tower license has reached capacity for the number of managed hosts allowed. No new hosts can be added. Existing " +
"playbooks can still be run against hosts already in inventory.</p>" +
"<p>If you are ready to upgrade, contact us at " + renew + ". Thanks!</p>";
} else {
// license is valid. the following text is displayed in the license viewer
title = "Update License";
html = "<div id=\"license-notification-body\"><div style=\"margin-top:5px; margin-bottom:25px;\"><p>The Ansible Tower license is valid.</p>" +
"<p>If you are ready to upgrade, contact us at " + renew + ". Thanks!</p>";
license_is_valid = true;
}
} else {
// No license
title = "Add Your License";
html = "<div id=\"license-notification-body\"><div style=\"margin-top:5px; margin-bottom:25px;\"><p>Now that youve successfully installed or upgraded Ansible Tower, the next step is to add a license file. " +
"If you dont have a license file yet, " + license_link + " to see all of our free and paid license options.</p>" +
"<p style=\"margin-top:15px; margin-bottom 15px; text-align:center;\"><a href=\"http://ansible.com/license\" target=\"_blank\" class=\"btn btn-danger free-button\">Get a Free Tower Trial License</a></p>";
}
if (IsAdmin()) {
html += "<p>Copy and paste the contents of your license in the field below, agree to the End User License Agreement, and click Submit.</p>";
} else {
html += "<p>A system administrator can install the new license by choosing View License on the Account Menu and clicking on the Update License tab.</p>";
}
html += "</div>";
if (IsAdmin()) {
html += GenerateForm.buildHTML(LicenseUpdateForm, { mode: 'edit', showButtons:((includeFormButton) ? true : false) });
}
html += "</div>";
result.body = html;
result.title = title;
return result;
},
postLicense: function(license_key, in_scope) {
var url = GetBasePath('config'),
self = this,
json_data, scope;
scope = (in_scope) ? in_scope : self.scope;
json_data = ToJSON('json', license_key);
json_data.eula_accepted = scope.eula_agreement;
if (typeof json_data === 'object' && Object.keys(json_data).length > 0) {
Rest.setUrl(url);
Rest.post(json_data)
.success(function (response) {
response.license_info = response;
Alert('License Accepted', 'The Ansible Tower license was updated. To review or update the license, choose View License from the Setup menu.','alert-info');
$rootScope.features = undefined;
Authorization.getLicense()
.success(function (data) {
Authorization.setLicense(data);
pendoService.issuePendoIdentity();
Wait("stop");
$location.path('/home');
})
.error(function () {
Wait('stop');
Alert('Error', 'Failed to access license information. GET returned status: ' + status, 'alert-danger',
$location.path('/logout'));
});
})
.catch(function (response) {
scope.license_json_api_error = "A valid license key in JSON format is required";
ProcessErrors(scope, response.data, response.status, null, { hdr: 'Error!',
msg: 'Failed to update license. POST returned: ' + response.status
});
});
} else {
scope.license_json_api_error = "A valid license key in JSON format is required";
}
},
test: function() {
var license = Store('license'),
self = this,
scope;
var getLicense = function() {
var deferred = $q.defer();
if (license === null) {
Rest.setUrl(GetBasePath('config'));
return Rest.get()
.then(function (data) {
license = data.data.license_info;
deferred.resolve();
return deferred.promise;
}, function () {
deferred.resolve();
return deferred.promise;
});
} else {
deferred.resolve(license);
return deferred.promise;
}
}
var promise = getLicense();
promise.then(function() {
self.scope = $rootScope.$new();
scope = self.scope;
if (license && typeof license === 'object' && Object.keys(license).length > 0) {
if (license.tested) {
return true;
}
license.tested = true;
Store('license',license); //update with tested flag
}
// Don't do anything when the license is valid
if (!self.shouldNotify(license)) {
return true; // if the license is valid it would exit 'test' here, otherwise it moves on to making the modal for the license
}
$location.path('/license');
});
},
GetLicense: function(callback, inScope) {
// Retrieve license detail
var self = this,
scope = (inScope) ? inScope : self.scope,
url = GetBasePath('config');
Rest.setUrl(url);
Rest.get()
.success(function (data) {
if (scope && callback) {
scope.$emit(callback, data);
}
else if (scope) {
scope.$emit('CheckLicenseReady', data);
}
})
.error(function (data, status) {
ProcessErrors($rootScope, data, status, null, { hdr: 'Error!',
msg: 'Failed to retrieve license. GET status: ' + status
});
});
}
};
}]);

View File

@ -1,390 +0,0 @@
/*************************************************
* Copyright (c) 2015 Ansible, Inc.
*
* All Rights Reserved
*************************************************/
/**
* @ngdoc function
* @name helpers.function:LogViewer
* @description logviewer
*/
export default
angular.module('LogViewerHelper', ['ModalDialog', 'Utilities', 'FormGenerator', 'VariablesHelper'])
.factory('LogViewer', ['$location', '$compile', 'CreateDialog', 'GetJob', 'Wait', 'GenerateForm', 'LogViewerStatusForm', 'AddTable', 'AddTextarea',
'LogViewerOptionsForm', 'EnvTable', 'GetBasePath', 'LookUpName', 'Empty', 'AddPreFormattedText', 'ParseVariableString', 'GetChoices',
function($location, $compile, CreateDialog, GetJob, Wait, GenerateForm, LogViewerStatusForm, AddTable, AddTextarea, LogViewerOptionsForm, EnvTable,
GetBasePath, LookUpName, Empty, AddPreFormattedText, ParseVariableString, GetChoices) {
return function(params) {
var parent_scope = params.scope,
url = params.url,
getIcon = params.getIcon,
scope = parent_scope.$new(true),
base = $location.path().replace(/^\//, '').split('/')[0],
pieces;
if (scope.removeModalReady) {
scope.removeModalReady();
}
scope.removeModalReady = scope.$on('ModalReady', function() {
Wait('stop');
$('#logviewer-modal-dialog').dialog('open');
});
if (scope.removeJobReady) {
scope.removeJobReady();
}
scope.removeJobReady = scope.$on('JobReady', function(e, data) {
var key, resizeText, elem;
$('#status-form-container').empty();
$('#options-form-container').empty();
$('#stdout-form-container').empty();
$('#traceback-form-container').empty();
$('#variables-container').empty();
$('#source-container').empty();
$('#logview-tabs li:eq(1)').hide();
$('#logview-tabs li:eq(2)').hide();
$('#logview-tabs li:eq(4)').hide();
$('#logview-tabs li:eq(5)').hide();
// Make sure subsequenct scope references don't bubble up to the parent
for (key in LogViewerStatusForm.fields) {
scope[key] = '';
}
for (key in LogViewerOptionsForm.fields) {
scope[key] = '';
}
for (key in data) {
scope[key] = data[key];
}
scope.created_by = '';
scope.job_template = '';
if (data.related.created_by) {
pieces = data.related.created_by.replace(/^\//,'').replace(/\/$/,'').split('/');
scope.created_by = parseInt(pieces[pieces.length - 1],10);
LookUpName({
scope: scope,
scope_var: 'created_by',
url: GetBasePath('users') + scope.created_by + '/'
});
}
// For jobs link the name to the job parent
if (base === 'jobs') {
if (data.type === 'job') {
scope.name_link = "job_template";
scope.job_template = data.unified_job_template;
scope.job_template_name = (data.summary_fields.job_template) ? data.summary_fields.job_template.name : data.name;
scope.name_id = data.unified_job_template;
}
if (data.type === 'project_update') {
scope.name_link = "project";
scope.name_id = data.unified_job_template;
}
if (data.type === 'inventory_update') {
scope.name_link = "inventory_source";
scope.name_id = scope.group;
}
}
AddTable({ scope: scope, form: LogViewerStatusForm, id: 'status-form-container', getIcon: getIcon });
AddTable({ scope: scope, form: LogViewerOptionsForm, id: 'options-form-container', getIcon: getIcon });
if (data.result_stdout) {
$('#logview-tabs li:eq(1)').show();
var showStandardOut = (data.type !== "system_job") ? true : false;
AddPreFormattedText({
id: 'stdout-form-container',
val: data.result_stdout,
standardOut: showStandardOut,
jobUrl: data.url
});
}
if (data.result_traceback) {
$('#logview-tabs li:eq(2)').show();
AddPreFormattedText({
id: 'traceback-form-container',
val: data.result_traceback
});
}
/*if (data.job_env) {
EnvTable({
id: 'env-form-container',
vars: data.job_env
});
}*/
if (data.extra_vars) {
$('#logview-tabs li:eq(4)').show();
AddTextarea({
container_id: 'variables-container',
fld_id: 'variables',
val: ParseVariableString(data.extra_vars)
});
}
if (data.source_vars) {
$('#logview-tabs li:eq(5)').show();
AddTextarea({
container_id: 'source-container',
fld_id: 'source-variables',
val: ParseVariableString(data.source_vars)
});
}
if (!Empty(scope.source)) {
if (scope.removeChoicesReady) {
scope.removeChoicesReady();
}
scope.removeChoicesReady = scope.$on('ChoicesReady', function() {
scope.source_choices.every(function(e) {
if (e.value === scope.source) {
scope.source = e.label;
return false;
}
return true;
});
});
GetChoices({
scope: scope,
url: GetBasePath('inventory_sources'),
field: 'source',
variable: 'source_choices',
choice_name: 'choices',
callback: 'ChoicesReady'
});
}
if (!Empty(scope.credential)) {
LookUpName({
scope: scope,
scope_var: 'credential',
url: GetBasePath('credentials') + scope.credential + '/'
});
}
if (!Empty(scope.inventory)) {
LookUpName({
scope: scope,
scope_var: 'inventory',
url: GetBasePath('inventory') + scope.inventory + '/'
});
}
if (!Empty(scope.project)) {
LookUpName({
scope: scope,
scope_var: 'project',
url: GetBasePath('projects') + scope.project + '/'
});
}
if (!Empty(scope.cloud_credential)) {
LookUpName({
scope: scope,
scope_var: 'cloud_credential',
url: GetBasePath('credentials') + scope.cloud_credential + '/'
});
}
if (!Empty(scope.inventory_source)) {
LookUpName({
scope: scope,
scope_var: 'inventory_source',
url: GetBasePath('inventory_sources') + scope.inventory_source + '/'
});
}
resizeText = function() {
var u = $('#logview-tabs').outerHeight() + 25,
h = $('#logviewer-modal-dialog').innerHeight(),
rows = Math.floor((h - u) / 20);
rows -= 3;
rows = (rows < 6) ? 6 : rows;
$('#logviewer-modal-dialog #variables').attr({ rows: rows });
$('#logviewer-modal-dialog #source-variables').attr({ rows: rows });
};
elem = angular.element(document.getElementById('logviewer-modal-dialog'));
$compile(elem)(scope);
CreateDialog({
scope: scope,
width: 600,
height: 550,
minWidth: 450,
callback: 'ModalReady',
id: 'logviewer-modal-dialog',
onResizeStop: resizeText,
title: 'Job Results',
onOpen: function() {
$('#logview-tabs a:first').tab('show');
$('#dialog-ok-button').focus();
resizeText();
}
});
});
GetJob({
url: url,
scope: scope
});
scope.modalOK = function() {
$('#logviewer-modal-dialog').dialog('close');
scope.$destroy();
};
};
}])
.factory('GetJob', ['Rest', 'ProcessErrors', function(Rest, ProcessErrors) {
return function(params) {
var url = params.url,
scope = params.scope;
Rest.setUrl(url);
Rest.get()
.success(function(data){
scope.$emit('JobReady', data);
})
.error(function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to retrieve ' + url + '. GET returned: ' + status });
});
};
}])
.factory('LookUpName', ['Rest', 'ProcessErrors', 'Empty', function(Rest, ProcessErrors, Empty) {
return function(params) {
var url = params.url,
scope_var = params.scope_var,
scope = params.scope;
Rest.setUrl(url);
Rest.get()
.success(function(data) {
if (scope_var === 'inventory_source') {
scope[scope_var + '_name'] = data.summary_fields.group.name;
}
else if (!Empty(data.name)) {
scope[scope_var + '_name'] = data.name;
}
if (!Empty(data.group)) {
// Used for inventory_source
scope.group = data.group;
}
})
.error(function(data, status) {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Failed to retrieve ' + url + '. GET returned: ' + status });
});
};
}])
.factory('AddTable', ['$compile', 'Empty', 'Find', function($compile, Empty, Find) {
return function(params) {
var form = params.form,
id = params.id,
scope = params.scope,
getIcon = params.getIcon,
fld, html, url, e,
urls = [
{ "variable": "credential", "url": "/#/credentials/" },
{ "variable": "project", "url": "/#/projects/" },
{ "variable": "inventory", "url": "/#/inventories/" },
{ "variable": "cloud_credential", "url": "/#/credentials/" },
{ "variable": "inventory_source", "url": "/#/home/groups/?id={{ group }}" },
{ "variable": "job_template", "url": "/#/job_templates/" },
{ "variable": "created_by", "url": "/#/users/" }
];
html = "<table class=\"table logviewer-status\">\n";
for (fld in form.fields) {
if (!Empty(scope[fld])) {
html += "<tr><td class=\"fld-label col-md-3 col-sm-3 col-xs-3\">" + form.fields[fld].label + "</td>" +
"<td>";
url = Find({ list: urls, key: "variable", val: fld });
if (url) {
html += "<a href=\"" + url.url;
html += (fld === "inventory_source") ? "" : scope[fld];
html += "\" ng-click=\"modalOK()\">{{ " + fld + '_name' + " }}</a>";
}
else if (fld === 'name' && scope.name_link) {
url = Find({ list: urls, key: "variable", val: scope.name_link });
html += "<a href=\"" + url.url + ( (scope.name_link === 'inventory_source') ? '' : scope.name_id ) + "\" ng-click=\"modalOK()\">{{ " +
( (scope.name_link === 'inventory_source') ? 'inventory_source_name' : fld ) + " }}</a>";
}
else if (fld === 'elapsed') {
html += scope[fld] + " <span class=\"small-text\">seconds</span>";
}
else if (fld === 'status') {
if (getIcon) {
html += "<i class=\"fa icon-job-" + getIcon(scope[fld]) + "\"></i> " + scope[fld];
}
else {
html += "<i class=\"fa icon-job-" + scope[fld] + "\"></i> " + scope[fld];
}
if (scope.job_explanation) {
html += "<p style=\"padding-top: 12px\">" + scope.job_explanation + "</p>";
}
}
else {
html += "{{ " + fld ;
html += (form.fields[fld].filter) ? " | " + form.fields[fld].filter : "" ;
html += " }}";
}
html += "</td></tr>\n";
}
}
html += "</table>\n";
e = angular.element(document.getElementById(id));
e.empty().html(html);
$compile(e)(scope);
};
}])
.factory('AddTextarea', [ function() {
return function(params) {
var container_id = params.container_id,
val = params.val,
fld_id = params.fld_id,
html;
html = "<div class=\"form-group\">\n" +
"<textarea id=\"" + fld_id + "\" ng-non-bindable class=\"form-control mono-space\" rows=\"12\" readonly>" + val + "</textarea>" +
"</div>\n";
$('#' + container_id).empty().html(html);
};
}])
.factory('AddPreFormattedText', ['$rootScope', function($rootScope) {
return function(params) {
var id = params.id,
val = params.val,
html = "";
if (params.standardOut) {
html += '<a ng-href="' + params.jobUrl + 'stdout?format=txt_download&token=' + $rootScope.token + '" class="btn btn-primary btn-xs DownloadStandardOut DownloadStandardOut--onModal" id="download-stdout-button" type="button" aw-tool-tip="Download standard out as a .txt file" data-placement="top" ng-show="status === \'cancelled\' || status === \'failed\' || status === \'error\' || status === \'successful\'"><i class="fa fa-download DownloadStandardOut-icon DownloadStandardOut-icon--withText"></i>Download</a>';
html += "<pre class='DownloadStandardOut-pre' ng-non-bindable>" + val + "</pre>\n";
} else {
html += "<pre ng-non-bindable>" + val + "</pre>\n";
}
$('#' + id).empty().html(html);
};
}])
.factory('EnvTable', [ function() {
return function(params) {
var id = params.id,
vars = params.vars,
key, html;
html = "<table class=\"table logviewer-status\">\n";
for (key in vars) {
html += "<tr><td class=\"fld-label col-md-4 col-sm-3 col-xs-3 break\">" + key + "</td>" +
"<td ng-non-bindable class=\"break\">" + vars[key] + "</td></tr>\n";
}
html += "</table>\n";
$('#' + id).empty().html(html);
};
}]);

View File

@ -32,14 +32,14 @@ export default
// Which page are we on?
if (Empty(next) && previous) {
// no next page, but there is a previous page
scope[iterator + '_page'] = parseInt(previous.match(/page=\d+/)[0].replace(/page=/, '')) + 1;
scope[iterator + '_page'] = scope[iterator + '_num_pages'];
} else if (next && Empty(previous)) {
// next page available, but no previous page
scope[iterator + '_page'] = 1;
$('#'+iterator+'-pagination #pagination-links li:eq(1)').attr('class', 'disabled');
} else if (next && previous) {
// we're in between next and previous
scope[iterator + '_page'] = parseInt(previous.match(/page=\d+/)[0].replace(/page=/, '')) + 1;
scope[iterator + '_page'] = /page=\d+/.test(previous) ? parseInt(previous.match(/page=(\d+)/)[1]) + 1 : 2;
}
// Calc the range of up to 10 pages to show

View File

@ -3,9 +3,32 @@
@import '../shared/branding/colors.less';
@import '../shared/branding/colors.default.less';
.JobDetail-panelHeader{
height: 50px;
.JobDetail{
display: flex;
flex-direction: row;
}
.JobDetail-leftSide{
flex: 1 0 auto;
width: 50%;
padding-right: 20px;
}
.JobDetail-rightSide{
flex: 1 0 auto;
width: 50%;
}
.JobDetail-panelHeader{
display: flex;
height: 30px;
}
.JobDetail-expandContainer{
flex: 1;
margin: 0px;
line-height: 30px;
white-space: nowrap;
}
.JobDetail-panelHeaderText{
@ -38,6 +61,7 @@
display: flex;
flex-wrap: wrap;
flex-direction: row;
padding-top: 25px;
}
.JobDetail-resultRow{
@ -45,6 +69,10 @@
display: flex;
}
.JobDetail-resultRowLabel{
text-transform: uppercase;
}
.JobDetail-resultRow label{
color: @default-interface-txt;
font-size: 14px;
@ -52,7 +80,103 @@
flex: 1 0 auto;
}
.JobDetail-resultRow--variables{
width: 100%;
display: flex;
flex-direction: column;
padding-left:15px;
}
.JobDetail-extraVars{
text-transform: none;
}
.JobDetail-extraVarsLabel{
margin-left:-15px;
padding-bottom: 15px;
}
.JobDetail-resultRowText{
width: 40%;
flex: 1 0 auto;
padding:0px;
text-transform: none;
}
.JobDetail-searchHeaderRow{
display: flex;
flex-wrap: wrap;
flex-direction: row;
height: 50px;
margin-top: 25px;
}
.JobDetail-searchContainer{
flex: 1 0 auto;
}
.JobDetail-tableToggleContainer{
flex: 1 0 auto;
display: flex;
justify-content: flex-end;
}
.JobDetail-tableToggle{
padding-left:10px;
padding-right: 10px;
border: 1px solid @default-second-border;
}
.JobDetail-tableToggle.active{
background-color: @default-link;
border: 1px solid @default-link;
color: @default-bg;
}
.JobDetail-tableToggle--left{
border-top-left-radius: 5px;
border-bottom-left-radius: 5px;
}
.JobDetail-tableToggle--right{
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
}
.JobDetail-searchInput{
border-radius: 5px !important;
}
.JobDetail-tableHeader:last-of-type{
text-align:justify;
}
.JobDetail-statusIcon{
padding-right: 10px;
padding-left: 10px;
}
.JobDetail-tableRow--selected,
.JobDetail-tableRow--selected > :first-child{
border-left: 5px solid @list-row-select-bord;
}
.JobDetail-tableRow--selected > :first-child > .JobDetail-statusIcon{
margin-left: -5px;
}
.JobDetail-statusIcon--results{
padding-left: 0px;
padding-right: 10px;
}
.JobDetail-graphSection{
height: 320px;
width:100%;
}
.JobDetail-stdoutActionButton--active{
flex:none;
width:0px;
padding-right: 0px;
}

View File

@ -19,7 +19,7 @@ export default
'EventViewer', 'DeleteJob', 'PlaybookRun', 'HostEventsViewer',
'LoadPlays', 'LoadTasks', 'LoadHosts', 'HostsEdit',
'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels',
'EditSchedule',
'EditSchedule', 'ParseTypeChange',
function(
$location, $rootScope, $filter, $scope, $compile, $stateParams,
$log, ClearScope, GetBasePath, Wait, Rest, ProcessErrors,
@ -28,7 +28,7 @@ export default
SetTaskStyles, DigestEvent, UpdateDOM, EventViewer, DeleteJob,
PlaybookRun, HostEventsViewer, LoadPlays, LoadTasks, LoadHosts,
HostsEdit, ParseVariableString, GetChoices, fieldChoices,
fieldLabels, EditSchedule
fieldLabels, EditSchedule, ParseTypeChange
) {
ClearScope();
@ -41,8 +41,9 @@ export default
job_type_options;
scope.plays = [];
scope.parseType = 'yaml';
scope.previousTaskFailed = false;
$scope.stdoutFullScreen = false;
scope.$watch('job_status', function(job_status) {
if (job_status && job_status.explanation && job_status.explanation.split(":")[0] === "Previous Task Failed") {
@ -201,6 +202,8 @@ export default
scope.haltEventQueue = false;
scope.processing = false;
scope.lessStatus = false;
scope.lessDetail = false;
scope.lessEvents = true;
scope.host_summary = {};
scope.host_summary.ok = 0;
@ -555,7 +558,7 @@ export default
});
});
if (scope.activeTask && scope.jobData.plays[scope.activePlay] && scope.jobData.plays[scope.activePlay].tasks[scope.activeTask]) {
scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'active';
scope.jobData.plays[scope.activePlay].tasks[scope.activeTask].taskActiveClass = 'JobDetail-tableRow--selected';
}
scope.$emit('LoadHosts');
})
@ -675,7 +678,7 @@ export default
scope.host_summary.failed;
});
if (scope.activePlay && scope.jobData.plays[scope.activePlay]) {
scope.jobData.plays[scope.activePlay].playActiveClass = 'active';
scope.jobData.plays[scope.activePlay].playActiveClass = 'JobDetail-tableRow--selected';
}
scope.$emit('LoadTasks', events_url);
})
@ -804,6 +807,7 @@ export default
return true;
});
//scope.setSearchAll('host');
ParseTypeChange({ scope: scope, field_id: 'pre-formatted-variables' });
scope.$emit('LoadPlays', data.related.job_events);
})
.error(function(data, status) {
@ -839,7 +843,6 @@ export default
$('.overlay').hide();
$('#summary-button').hide();
$('#hide-summary-button').hide();
$('#job-detail-container').css({ "width": "58.33333333%", "padding-right": "7px" });
$('#job-summary-container .job_well').css({
'box-shadow': 'none',
'height': 'auto'
@ -859,12 +862,12 @@ export default
// Detail table height adjusting. First, put page height back to 'normal'.
$('#plays-table-detail').height(80);
//$('#plays-table-detail').mCustomScrollbar("update");
$('#tasks-table-detail').height(120);
// $('#tasks-table-detail').height(120);
//$('#tasks-table-detail').mCustomScrollbar("update");
$('#hosts-table-detail').height(150);
//$('#hosts-table-detail').mCustomScrollbar("update");
height = $(window).height() - $('#main-menu-container .navbar').outerHeight() -
$('#job-detail-container').outerHeight() - $('#job-detail-footer').outerHeight() - 20;
$('#job-detail-container').outerHeight() - 20;
if (height > 15) {
// there's a bunch of white space at the bottom, let's use it
$('#plays-table-detail').height(80 + (height * 0.10));
@ -872,10 +875,9 @@ export default
$('#hosts-table-detail').height(150 + (height * 0.70));
}
// Summary table height adjusting.
height = ($('#job-detail-container').height() / 2) - $('#hosts-summary-section .header').outerHeight() -
$('#hosts-summary-section .table-header').outerHeight() -
$('#summary-search-section').outerHeight() - 20;
$('#hosts-summary-table').height(height);
height = ($('#job-detail-container').height() / 2) - $('#hosts-summary-section .JobDetail-searchHeaderRow').outerHeight() -
$('#hosts-summary-section .table-header').outerHeight() - 20;
// $('#hosts-summary-table').height(height);
//$('#hosts-summary-table').mCustomScrollbar("update");
scope.$emit('RefreshCompleted');
};
@ -980,15 +982,38 @@ export default
scope.toggleLessStatus = function() {
if (!scope.lessStatus) {
$('#job-status-form .toggle-show').slideUp(200);
$('#job-status-form').slideUp(200);
scope.lessStatus = true;
}
else {
$('#job-status-form .toggle-show').slideDown(200);
$('#job-status-form').slideDown(200);
scope.lessStatus = false;
}
};
scope.toggleLessDetail = function() {
if (!scope.lessDetail) {
$('#job-detail-details').slideUp(200);
scope.lessDetail = true;
}
else {
$('#job-detail-details').slideDown(200);
scope.lessDetail = false;
}
};
scope.toggleLessEvents = function() {
if (!scope.lessEvents) {
$('#events-summary').slideUp(200);
scope.lessEvents = true;
}
else {
$('#events-summary').slideDown(200);
scope.lessEvents = false;
DrawGraph({scope:scope});
}
};
scope.filterPlayStatus = function() {
scope.search_play_status = (scope.search_play_status === 'all') ? 'failed' : 'all';
if (!scope.liveEventProcessing || scope.pauseLiveEvents) {
@ -1409,16 +1434,10 @@ export default
$scope.$emit('LoadJob');
};
scope.editHost = function(id) {
HostsEdit({
host_scope: scope,
group_scope: null,
host_id: id,
inventory_id: scope.job.inventory,
mode: 'edit', // 'add' or 'edit'
selected_group_id: null
});
};
// Click binding for the expand/collapse button on the standard out log
$scope.toggleStdoutFullscreen = function() {
$scope.stdoutFullScreen = !$scope.stdoutFullScreen;
}
scope.editSchedule = function() {
// We need to get the schedule's ID out of the related links

Some files were not shown because too many files have changed in this diff Show More