Merge branch 'release_3.0.0' into devel

* release_3.0.0: (129 commits)
  Use the correct yum attribute name
  Fix NaN / invalid end date value in schedule edit forms (#3007)
  resolves kickback on #2976 (#3009)
  Additional UI/JS licenses (#2974)
  Install at when configuring on-book AMI conf
  Added patch tests for updating project organizations
  Make development environment use ATOMIC_REQUESTS
  Team organization field made non-null
  Don't let normal users create orgless projects
  Fix up some flake8 issues
  Add delete protection from certain objects
  Cascade delete teams when their organization is deleted
  Updated tests to reflect credential access after migrations
  Changed conditonal to repair console error and page loade error
  Remove socket binding when the scope is destroyed so that we don't make errant GET requests in future instances of this controller.
  updated EULA text from rnalen
  Fix team credential role access in rbac migration
  Orphan handling in _old_access.py
  Normalized CustomInventoryScriptAccess.can_admin
  Use simplified epel-release links
  ...
This commit is contained in:
Matthew Jones 2016-07-14 16:22:26 -04:00
commit ba444ab743
123 changed files with 1970 additions and 1363 deletions

View File

@ -87,6 +87,7 @@ SUMMARIZABLE_FK_FIELDS = {
'current_update': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
'current_job': DEFAULT_SUMMARY_FIELDS + ('status', 'failed', 'license_error'),
'inventory_source': ('source', 'last_updated', 'status'),
'custom_inventory_script': DEFAULT_SUMMARY_FIELDS,
'source_script': ('name', 'description'),
'role': ('id', 'role_field'),
'notification_template': DEFAULT_SUMMARY_FIELDS,
@ -917,6 +918,19 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
args=(obj.last_update.pk,))
return res
def validate(self, attrs):
organization = None
if 'organization' in attrs:
organization = attrs['organization']
elif self.instance:
organization = self.instance.organization
view = self.context.get('view', None)
if not organization and not view.request.user.is_superuser:
# Only allow super users to create orgless projects
raise serializers.ValidationError('Organization is missing')
return super(ProjectSerializer, self).validate(attrs)
class ProjectPlaybooksSerializer(ProjectSerializer):
@ -1926,12 +1940,11 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
def to_internal_value(self, data):
# When creating a new job and a job template is specified, populate any
# fields not provided in data from the job template.
if not self.instance and isinstance(data, dict) and 'job_template' in data:
if not self.instance and isinstance(data, dict) and data.get('job_template', False):
try:
job_template = JobTemplate.objects.get(pk=data['job_template'])
except JobTemplate.DoesNotExist:
self._errors = {'job_template': 'Invalid job template.'}
return
raise serializers.ValidationError({'job_template': 'Invalid job template.'})
data.setdefault('name', job_template.name)
data.setdefault('description', job_template.description)
data.setdefault('job_type', job_template.job_type)
@ -2427,14 +2440,10 @@ class NotificationTemplateSerializer(BaseSerializer):
notification_type = attrs['notification_type']
elif self.instance:
notification_type = self.instance.notification_type
if 'organization' in attrs:
organization = attrs['organization']
elif self.instance:
organization = self.instance.organization
else:
notification_type = None
if not notification_type:
raise serializers.ValidationError('Missing required fields for Notification Configuration: notification_type')
if not organization:
raise serializers.ValidationError("Missing 'organization' from required fields")
notification_class = NotificationTemplate.CLASS_FOR_NOTIFICATION_TYPE[notification_type]
missing_fields = []
@ -2621,7 +2630,11 @@ class ActivityStreamSerializer(BaseSerializer):
if getattr(obj, fk).exists():
rel[fk] = []
for thisItem in allm2m:
rel[fk].append(reverse('api:' + fk + '_detail', args=(thisItem.id,)))
if fk == 'custom_inventory_script':
rel[fk].append(reverse('api:inventory_script_detail', args=(thisItem.id,)))
else:
rel[fk].append(reverse('api:' + fk + '_detail', args=(thisItem.id,)))
if fk == 'schedule':
rel['unified_job_template'] = thisItem.unified_job_template.get_absolute_url()
return rel
@ -2694,8 +2707,7 @@ class TowerSettingsSerializer(BaseSerializer):
def to_internal_value(self, data):
if data['key'] not in settings.TOWER_SETTINGS_MANIFEST:
self._errors = {'key': 'Key {0} is not a valid settings key.'.format(data['key'])}
return
raise serializers.ValidationError({'key': ['Key {0} is not a valid settings key.'.format(data['key'])]})
ret = super(TowerSettingsSerializer, self).to_internal_value(data)
manifest_val = settings.TOWER_SETTINGS_MANIFEST[data['key']]
ret['description'] = manifest_val['description']

View File

@ -0,0 +1,9 @@
{% include "api/sub_list_create_api_view.md" %}
Labels not associated with any other resources are deleted. A label can become disassociated with a resource as a result of 3 events.
1. A label is explicitly diassociated with a related job template
2. A job is deleted with labels
3. A cleanup job deletes a job with labels
{% include "api/_new_in_awx.md" %}

View File

@ -34,7 +34,7 @@ existing {{ model_verbose_name }} with this {{ parent_model_verbose_name }}.
Make a POST request to this resource with `id` and `disassociate` fields to
remove the {{ model_verbose_name }} from this {{ parent_model_verbose_name }}
without deleting the {{ model_verbose_name }}.
{% if model_verbose_name != "label" %} without deleting the {{ model_verbose_name }}{% endif %}.
{% endif %}
{% endif %}

View File

@ -1,3 +1,19 @@
TOWER SOFTWARE END USER LICENSE AGREEMENT
ANSIBLE TOWER BY RED HAT END USER LICENSE AGREEMENT
Unless otherwise agreed to, and executed in a definitive agreement, between Ansible, Inc. (“Ansible”) and the individual or entity (“Customer”) signing or electronically accepting these terms of use for the Tower Software (“EULA”), all Tower Software, including any and all versions released or made available by Ansible, shall be subject to the Ansible Software Subscription and Services Agreement found at www.ansible.com/subscription-agreement (“Agreement”). Ansible is not responsible for any additional obligations, conditions or warranties agreed to between Customer and an authorized distributor, or reseller, of the Tower Software. BY DOWNLOADING AND USING THE TOWER SOFTWARE, OR BY CLICKING ON THE “YES” BUTTON OR OTHER BUTTON OR MECHANISM DESIGNED TO ACKNOWLEDGE CONSENT TO THE TERMS OF AN ELECTRONIC COPY OF THIS EULA, THE CUSTOMER HEREBY ACKNOWLEDGES THAT CUSTOMER HAS READ, UNDERSTOOD, AND AGREES TO BE BOUND BY THE TERMS OF THIS EULA AND AGREEMENT, INCLUDING ALL TERMS INCORPORATED HEREIN BY REFERENCE, AND THAT THIS EULA AND AGREEMENT IS EQUIVALENT TO ANY WRITTEN NEGOTIATED AGREEMENT BETWEEN CUSTOMER AND ANSIBLE. THIS EULA AND AGREEMENT IS ENFORCEABLE AGAINST ANY PERSON OR ENTITY THAT USES OR AVAILS ITSELF OF THE TOWER SOFTWARE OR ANY PERSON OR ENTITY THAT USES THE OR AVAILS ITSELF OF THE TOWER SOFTWARE ON ANOTHER PERSONS OR ENTITYS BEHALF.
This end user license agreement (“EULA”) governs the use of the Ansible Tower software and any related updates, upgrades, versions, appearance, structure and organization (the “Ansible Tower Software”), regardless of the delivery mechanism.
1. License Grant. Subject to the terms of this EULA, Red Hat, Inc. and its affiliates (“Red Hat”) grant to you (“You”) a non-transferable, non-exclusive, worldwide, non-sublicensable, limited, revocable license to use the Ansible Tower Software for the term of the associated Red Hat Software Subscription(s) and in a quantity equal to the number of Red Hat Software Subscriptions purchased from Red Hat for the Ansible Tower Software (“License”), each as set forth on the applicable Red Hat ordering document. You acquire only the right to use the Ansible Tower Software and do not acquire any rights of ownership. Red Hat reserves all rights to the Ansible Tower Software not expressly granted to You. This License grant pertains solely to Your use of the Ansible Tower Software and is not intended to limit Your rights under, or grant You rights that supersede, the license terms of any software packages which may be made available with the Ansible Tower Software that are subject to an open source software license.
2. Intellectual Property Rights. Title to the Ansible Tower Software and each component, copy and modification, including all derivative works whether made by Red Hat, You or on Red Hat's behalf, including those made at Your suggestion and all associated intellectual property rights, are and shall remain the sole and exclusive property of Red Hat and/or it licensors. The License does not authorize You (nor may You allow any third party, specifically non-employees of Yours) to: (a) copy, distribute, reproduce, use or allow third party access to the Ansible Tower Software except as expressly authorized hereunder; (b) decompile, disassemble, reverse engineer, translate, modify, convert or apply any procedure or process to the Ansible Tower Software in order to ascertain, derive, and/or appropriate for any reason or purpose, including the Ansible Tower Software source code or source listings or any trade secret information or process contained in the Ansible Tower Software (except as permitted under applicable law); (c) execute or incorporate other software (except for approved software as appears in the Ansible Tower Software documentation or specifically approved by Red Hat in writing) into Ansible Tower Software, or create a derivative work of any part of the Ansible Tower Software; (d) remove any trademarks, trade names or titles, copyrights legends or any other proprietary marking on the Ansible Tower Software; (e) disclose the results of any benchmarking of the Ansible Tower Software (whether or not obtained with Red Hats assistance) to any third party; (f) attempt to circumvent any user limits or other license, timing or use restrictions that are built into, defined or agreed upon, regarding the Ansible Tower Software. You are hereby notified that the Ansible Tower Software may contain time-out devices, counter devices, and/or other devices intended to ensure the limits of the License will not be exceeded (“Limiting Devices”). If the Ansible Tower Software contains Limiting Devices, Red Hat will provide You materials necessary to use the Ansible Tower Software to the extent permitted. You may not tamper with or otherwise take any action to defeat or circumvent a Limiting Device or other control measure, including but not limited to, resetting the unit amount or using false host identification number for the purpose of extending any term of the License.
3. Evaluation Licenses. Unless You have purchased Ansible Tower Software Subscriptions from Red Hat or an authorized reseller under the terms of a commercial agreement with Red Hat, all use of the Ansible Tower Software shall be limited to testing purposes and not for production use (“Evaluation”). Unless otherwise agreed by Red Hat, Evaluation of the Ansible Tower Software shall be limited to an evaluation environment and the Ansible Tower Software shall not be used to manage any systems or virtual machines on networks being used in the operation of Your business or any other non-evaluation purpose. Unless otherwise agreed by Red Hat, You shall limit all Evaluation use to a single 30 day evaluation period and shall not download or otherwise obtain additional copies of the Ansible Tower Software or license keys for Evaluation.
4. Limited Warranty. Except as specifically stated in this Section 4, to the maximum extent permitted under applicable law, the Ansible Tower Software and the components are provided and licensed “as is” without warranty of any kind, expressed or implied, including the implied warranties of merchantability, non-infringement or fitness for a particular purpose. Red Hat warrants solely to You that the media on which the Ansible Tower Software may be furnished will be free from defects in materials and manufacture under normal use for a period of thirty (30) days from the date of delivery to You. Red Hat does not warrant that the functions contained in the Ansible Tower Software will meet Your requirements or that the operation of the Ansible Tower Software will be entirely error free, appear precisely as described in the accompanying documentation, or comply with regulatory requirements.
5. Limitation of Remedies and Liability. To the maximum extent permitted by applicable law, Your exclusive remedy under this EULA is to return any defective media within thirty (30) days of delivery along with a copy of Your payment receipt and Red Hat, at its option, will replace it or refund the money paid by You for the media. To the maximum extent permitted under applicable law, neither Red Hat nor any Red Hat authorized distributor will be liable to You for any incidental or consequential damages, including lost profits or lost savings arising out of the use or inability to use the Ansible Tower Software or any component, even if Red Hat or the authorized distributor has been advised of the possibility of such damages. In no event shall Red Hat's liability or an authorized distributors liability exceed the amount that You paid to Red Hat for the Ansible Tower Software during the twelve months preceding the first event giving rise to liability.
6. Export Control. In accordance with the laws of the United States and other countries, You represent and warrant that You: (a) understand that the Ansible Tower Software and its components may be subject to export controls under the U.S. Commerce Departments Export Administration Regulations (“EAR”); (b) are not located in any country listed in Country Group E:1 in Supplement No. 1 to part 740 of the EAR; (c) will not export, re-export, or transfer the Ansible Tower Software to any prohibited destination or to any end user who has been prohibited from participating in US export transactions by any federal agency of the US government; (d) will not use or transfer the Ansible Tower Software for use in connection with the design, development or production of nuclear, chemical or biological weapons, or rocket systems, space launch vehicles, or sounding rockets or unmanned air vehicle systems; (e) understand and agree that if you are in the United States and you export or transfer the Ansible Tower Software to eligible end users, you will, to the extent required by EAR Section 740.17 obtain a license for such export or transfer and will submit semi-annual reports to the Commerce Departments Bureau of Industry and Security, which include the name and address (including country) of each transferee; and (f) understand that countries including the United States may restrict the import, use, or export of encryption products (which may include the Ansible Tower Software) and agree that you shall be solely responsible for compliance with any such import, use, or export restrictions.
7. General. If any provision of this EULA is held to be unenforceable, that shall not affect the enforceability of the remaining provisions. This agreement shall be governed by the laws of the State of New York and of the United States, without regard to any conflict of laws provisions. The rights and obligations of the parties to this EULA shall not be governed by the United Nations Convention on the International Sale of Goods.
Copyright © 2015 Red Hat, Inc. All rights reserved. "Red Hat" and “Ansible Tower” are registered trademarks of Red Hat, Inc. All other trademarks are the property of their respective owners.

View File

@ -958,6 +958,7 @@ class ProjectList(ListCreateAPIView):
'admin_role',
'use_role',
'update_role',
'read_role',
)
return projects_qs
@ -1401,7 +1402,7 @@ class OrganizationCredentialList(SubListCreateAPIView):
user_visible = Credential.accessible_objects(self.request.user, 'read_role').all()
org_set = Credential.accessible_objects(organization.admin_role, 'read_role').all()
if self.request.user.is_superuser:
if self.request.user.is_superuser or self.request.user.is_system_auditor:
return org_set
return org_set & user_visible
@ -1487,7 +1488,7 @@ class InventoryList(ListCreateAPIView):
def get_queryset(self):
qs = Inventory.accessible_objects(self.request.user, 'read_role')
qs = qs.select_related('admin_role', 'read_role', 'update_role', 'use_role')
qs = qs.select_related('admin_role', 'read_role', 'update_role', 'use_role', 'adhoc_role')
return qs
class InventoryDetail(RetrieveUpdateDestroyAPIView):
@ -2188,9 +2189,6 @@ class JobTemplateDetail(RetrieveUpdateDestroyAPIView):
can_delete = request.user.can_access(JobTemplate, 'delete', obj)
if not can_delete:
raise PermissionDenied("Cannot delete job template.")
if obj.jobs.filter(status__in=['new', 'pending', 'waiting', 'running']).exists():
return Response({"error": "Delete not allowed while there are jobs running"},
status=status.HTTP_405_METHOD_NOT_ALLOWED)
return super(JobTemplateDetail, self).destroy(request, *args, **kwargs)
@ -2591,7 +2589,7 @@ class SystemJobTemplateList(ListAPIView):
serializer_class = SystemJobTemplateSerializer
def get(self, request, *args, **kwargs):
if not request.user.is_superuser:
if not request.user.is_superuser and not request.user.is_system_auditor:
raise PermissionDenied("Superuser privileges needed.")
return super(SystemJobTemplateList, self).get(request, *args, **kwargs)
@ -3321,7 +3319,7 @@ class SystemJobList(ListCreateAPIView):
serializer_class = SystemJobListSerializer
def get(self, request, *args, **kwargs):
if not request.user.is_superuser:
if not request.user.is_superuser and not request.user.is_system_auditor:
raise PermissionDenied("Superuser privileges needed.")
return super(SystemJobList, self).get(request, *args, **kwargs)
@ -3625,8 +3623,6 @@ class RoleList(ListAPIView):
new_in_300 = True
def get_queryset(self):
if self.request.user.is_superuser:
return Role.objects.all()
return Role.visible_roles(self.request.user)
@ -3652,10 +3648,10 @@ class RoleUsersList(SubListCreateAttachDetachAPIView):
return role.members.all()
def post(self, request, *args, **kwargs):
# Forbid implicit role creation here
# Forbid implicit user creation here
sub_id = request.data.get('id', None)
if not sub_id:
data = dict(msg="Role 'id' field is missing.")
data = dict(msg="User 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(RoleUsersList, self).post(request, *args, **kwargs)

View File

@ -12,11 +12,12 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
# Django REST Framework
from rest_framework.exceptions import ParseError, PermissionDenied
from rest_framework.exceptions import ParseError, PermissionDenied, ValidationError
# AWX
from awx.main.utils import * # noqa
from awx.main.models import * # noqa
from awx.main.models.unified_jobs import ACTIVE_STATES
from awx.main.models.mixins import ResourceMixin
from awx.api.license import LicenseForbids
from awx.main.task_engine import TaskSerializer
@ -139,7 +140,7 @@ class BaseAccess(object):
self.user = user
def get_queryset(self):
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return self.model.objects.all()
else:
return self.model.objects.none()
@ -221,7 +222,7 @@ class UserAccess(BaseAccess):
model = User
def get_queryset(self):
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return User.objects.all()
if tower_settings.ORG_ADMINS_CAN_SEE_ALL_USERS and \
@ -310,7 +311,16 @@ class OrganizationAccess(BaseAccess):
def can_delete(self, obj):
self.check_license(feature='multiple_organizations', check_expiration=False)
return self.can_change(obj, None)
is_change_possible = self.can_change(obj, None)
if not is_change_possible:
return False
active_jobs = []
active_jobs.extend(Job.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES))
active_jobs.extend(ProjectUpdate.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES))
active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__inventory__organization=obj, status__in=ACTIVE_STATES))
if len(active_jobs) > 0:
raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs)))
return True
class InventoryAccess(BaseAccess):
'''
@ -373,7 +383,15 @@ class InventoryAccess(BaseAccess):
return self.user in obj.admin_role
def can_delete(self, obj):
return self.can_admin(obj, None)
is_can_admin = self.can_admin(obj, None)
if not is_can_admin:
return False
active_jobs = []
active_jobs.extend(Job.objects.filter(inventory=obj, status__in=ACTIVE_STATES))
active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__inventory=obj, status__in=ACTIVE_STATES))
if len(active_jobs) > 0:
raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs)))
return True
def can_run_ad_hoc_commands(self, obj):
return self.user in obj.adhoc_role
@ -486,7 +504,14 @@ class GroupAccess(BaseAccess):
return True
def can_delete(self, obj):
return obj and self.user in obj.inventory.admin_role
is_delete_allowed = bool(obj and self.user in obj.inventory.admin_role)
if not is_delete_allowed:
return False
active_jobs = []
active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__in=obj.inventory_sources.all(), status__in=ACTIVE_STATES))
if len(active_jobs) > 0:
raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs)))
return True
class InventorySourceAccess(BaseAccess):
'''
@ -589,8 +614,9 @@ class CredentialAccess(BaseAccess):
def can_read(self, obj):
return self.user in obj.read_role
@check_superuser
def can_add(self, data):
if self.user.is_superuser:
if not data: # So the browseable API will work
return True
user_pk = get_pk_from_dict(data, 'user')
if user_pk:
@ -660,6 +686,8 @@ class TeamAccess(BaseAccess):
@check_superuser
def can_add(self, data):
if not data: # So the browseable API will work
return Organization.accessible_objects(self.user, 'admin_role').exists()
org_pk = get_pk_from_dict(data, 'organization')
org = get_object_or_400(Organization, pk=org_pk)
if self.user in org.admin_role:
@ -715,13 +743,15 @@ class ProjectAccess(BaseAccess):
model = Project
def get_queryset(self):
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return self.model.objects.all()
qs = self.model.accessible_objects(self.user, 'read_role')
return qs.select_related('modified_by', 'credential', 'current_job', 'last_job').all()
@check_superuser
def can_add(self, data):
if not data: # So the browseable API will work
return Organization.accessible_objects(self.user, 'admin_role').exists()
organization_pk = get_pk_from_dict(data, 'organization')
org = get_object_or_400(Organization, pk=organization_pk)
return self.user in org.admin_role
@ -731,7 +761,15 @@ class ProjectAccess(BaseAccess):
return self.user in obj.admin_role
def can_delete(self, obj):
return self.can_change(obj, None)
is_change_allowed = self.can_change(obj, None)
if not is_change_allowed:
return False
active_jobs = []
active_jobs.extend(Job.objects.filter(project=obj, status__in=ACTIVE_STATES))
active_jobs.extend(ProjectUpdate.objects.filter(project=obj, status__in=ACTIVE_STATES))
if len(active_jobs) > 0:
raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs)))
return True
@check_superuser
def can_start(self, obj):
@ -747,7 +785,7 @@ class ProjectUpdateAccess(BaseAccess):
model = ProjectUpdate
def get_queryset(self):
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return self.model.objects.all()
qs = ProjectUpdate.objects.distinct()
qs = qs.select_related('created_by', 'modified_by', 'project')
@ -783,7 +821,7 @@ class JobTemplateAccess(BaseAccess):
model = JobTemplate
def get_queryset(self):
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
qs = self.model.objects.all()
else:
qs = self.model.accessible_objects(self.user, 'read_role')
@ -802,7 +840,7 @@ class JobTemplateAccess(BaseAccess):
given action as well as the 'create' deploy permission.
Users who are able to create deploy jobs can also run normal and check (dry run) jobs.
'''
if not data or '_method' in data: # So the browseable API will work?
if not data: # So the browseable API will work
return True
# if reference_obj is provided, determine if it can be coppied
@ -953,7 +991,13 @@ class JobTemplateAccess(BaseAccess):
@check_superuser
def can_delete(self, obj):
return self.user in obj.admin_role
is_delete_allowed = self.user in obj.admin_role
if not is_delete_allowed:
return False
active_jobs = obj.jobs.filter(status__in=ACTIVE_STATES)
if len(active_jobs) > 0:
raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs)))
return True
class JobAccess(BaseAccess):
'''
@ -974,7 +1018,7 @@ class JobAccess(BaseAccess):
qs = qs.select_related('created_by', 'modified_by', 'job_template', 'inventory',
'project', 'credential', 'cloud_credential', 'job_template')
qs = qs.prefetch_related('unified_job_template')
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs.all()
qs_jt = qs.filter(
@ -992,7 +1036,7 @@ class JobAccess(BaseAccess):
Q(project__organization__in=org_access_qs)).distinct()
def can_add(self, data):
if not data or '_method' in data: # So the browseable API will work?
if not data: # So the browseable API will work
return True
if not self.user.is_superuser:
return False
@ -1073,10 +1117,7 @@ class AdHocCommandAccess(BaseAccess):
'''
I can only see/run ad hoc commands when:
- I am a superuser.
- I am an org admin and have permission to read the credential.
- I am a normal user with a user/team permission that has at least read
permission on the inventory and the run_ad_hoc_commands flag set, and I
can read the credential.
- I have read access to the inventory
'''
model = AdHocCommand
@ -1084,26 +1125,23 @@ class AdHocCommandAccess(BaseAccess):
qs = self.model.objects.distinct()
qs = qs.select_related('created_by', 'modified_by', 'inventory',
'credential')
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs.all()
credential_ids = set(self.user.get_queryset(Credential).values_list('id', flat=True))
inventory_qs = Inventory.accessible_objects(self.user, 'read_role')
return qs.filter(credential_id__in=credential_ids,
inventory__in=inventory_qs)
return qs.filter(inventory__in=inventory_qs)
def can_add(self, data):
if not data or '_method' in data: # So the browseable API will work?
if not data: # So the browseable API will work
return True
self.check_license()
# If a credential is provided, the user should have read access to it.
# If a credential is provided, the user should have use access to it.
credential_pk = get_pk_from_dict(data, 'credential')
if credential_pk:
credential = get_object_or_400(Credential, pk=credential_pk)
if self.user not in credential.read_role:
if self.user not in credential.use_role:
return False
# Check that the user has the run ad hoc command permission on the
@ -1148,7 +1186,7 @@ class AdHocCommandEventAccess(BaseAccess):
qs = self.model.objects.distinct()
qs = qs.select_related('ad_hoc_command', 'host')
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs.all()
ad_hoc_command_qs = self.user.get_queryset(AdHocCommand)
host_qs = self.user.get_queryset(Host)
@ -1174,7 +1212,7 @@ class JobHostSummaryAccess(BaseAccess):
def get_queryset(self):
qs = self.model.objects
qs = qs.select_related('job', 'job__job_template', 'host')
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs.all()
job_qs = self.user.get_queryset(Job)
host_qs = self.user.get_queryset(Host)
@ -1206,7 +1244,7 @@ class JobEventAccess(BaseAccess):
event_data__icontains='"ansible_job_id": "',
event_data__contains='"module_name": "async_status"')
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs.all()
job_qs = self.user.get_queryset(Job)
@ -1319,7 +1357,7 @@ class ScheduleAccess(BaseAccess):
qs = self.model.objects.all()
qs = qs.select_related('created_by', 'modified_by')
qs = qs.prefetch_related('unified_job_template')
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs.all()
job_template_qs = self.user.get_queryset(JobTemplate)
inventory_source_qs = self.user.get_queryset(InventorySource)
@ -1370,14 +1408,19 @@ class NotificationTemplateAccess(BaseAccess):
def get_queryset(self):
qs = self.model.objects.all()
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs
return self.model.objects.filter(organization__in=Organization.accessible_objects(self.user, 'admin_role').all())
return self.model.objects.filter(
Q(organization__in=self.user.admin_of_organizations) |
Q(organization__in=self.user.auditor_of_organizations)
).distinct()
@check_superuser
def can_read(self, obj):
if self.user.is_superuser or self.user.is_system_auditor:
return True
if obj.organization is not None:
return self.user in obj.organization.admin_role
if self.user in obj.organization.admin_role or self.user in obj.organization.auditor_role:
return True
return False
@check_superuser
@ -1414,9 +1457,12 @@ class NotificationAccess(BaseAccess):
def get_queryset(self):
qs = self.model.objects.all()
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return qs
return self.model.objects.filter(notification_template__organization__in=Organization.accessible_objects(self.user, 'admin_role'))
return self.model.objects.filter(
Q(notification_template__organization__in=self.user.admin_of_organizations) |
Q(notification_template__organization__in=self.user.auditor_of_organizations)
).distinct()
def can_read(self, obj):
return self.user.can_access(NotificationTemplate, 'read', obj.notification_template)
@ -1431,7 +1477,7 @@ class LabelAccess(BaseAccess):
model = Label
def get_queryset(self):
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return self.model.objects.all()
return self.model.objects.filter(
organization__in=Organization.accessible_objects(self.user, 'read_role')
@ -1443,7 +1489,7 @@ class LabelAccess(BaseAccess):
@check_superuser
def can_add(self, data):
if not data or '_method' in data: # So the browseable API will work?
if not data: # So the browseable API will work
return True
org_pk = get_pk_from_dict(data, 'organization')
@ -1494,9 +1540,7 @@ class ActivityStreamAccess(BaseAccess):
'inventory_update', 'credential', 'team', 'project', 'project_update',
'permission', 'job_template', 'job', 'ad_hoc_command',
'notification_template', 'notification', 'label', 'role')
if self.user.is_superuser:
return qs.all()
if self.user in Role.singleton('system_auditor'):
if self.user.is_superuser or self.user.is_system_auditor:
return qs.all()
inventory_set = Inventory.accessible_objects(self.user, 'read_role')
@ -1544,18 +1588,20 @@ class CustomInventoryScriptAccess(BaseAccess):
model = CustomInventoryScript
def get_queryset(self):
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return self.model.objects.distinct().all()
return self.model.accessible_objects(self.user, 'read_role').all()
@check_superuser
def can_add(self, data):
if not data: # So the browseable API will work
return Organization.accessible_objects(self.user, 'admin_role').exists()
org_pk = get_pk_from_dict(data, 'organization')
org = get_object_or_400(Organization, pk=org_pk)
return self.user in org.admin_role
@check_superuser
def can_admin(self, obj):
def can_admin(self, obj, data=None):
return self.user in obj.admin_role
@check_superuser
@ -1566,10 +1612,6 @@ class CustomInventoryScriptAccess(BaseAccess):
def can_delete(self, obj):
return self.can_admin(obj)
@check_superuser
def can_read(self, obj):
return self.user in obj.read_role
class TowerSettingsAccess(BaseAccess):
'''
@ -1598,7 +1640,7 @@ class RoleAccess(BaseAccess):
def can_read(self, obj):
if not obj:
return False
if self.user.is_superuser:
if self.user.is_superuser or self.user.is_system_auditor:
return True
if obj.object_id:

View File

@ -24,7 +24,7 @@ def create_system_job_templates(apps, schema_editor):
job_type='cleanup_jobs',
defaults=dict(
name='Cleanup Job Details',
description='Remove job history older than X days',
description='Remove job history',
created=now_dt,
modified=now_dt,
polymorphic_ctype=sjt_ct,
@ -51,7 +51,7 @@ def create_system_job_templates(apps, schema_editor):
job_type='cleanup_activitystream',
defaults=dict(
name='Cleanup Activity Stream',
description='Remove activity stream history older than X days',
description='Remove activity stream history',
created=now_dt,
modified=now_dt,
polymorphic_ctype=sjt_ct,

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from awx.main.migrations import _rbac as rbac
from awx.main.migrations import _team_cleanup as team_cleanup
from awx.main.migrations import _migration_utils as migration_utils
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0026_v300_credential_unique'),
]
operations = [
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
migrations.RunPython(team_cleanup.migrate_team),
migrations.RunPython(rbac.rebuild_role_hierarchy),
]

View File

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import awx.main.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0027_v300_team_migrations'),
]
operations = [
migrations.AlterField(
model_name='team',
name='organization',
field=models.ForeignKey(related_name='teams', to='main.Organization'),
preserve_default=False,
),
]

View File

@ -656,7 +656,7 @@ class TeamAccess(BaseAccess):
raise PermissionDenied('Unable to change organization on a team')
if self.user.is_superuser:
return True
if self.user in obj.organization.deprecated_admins.all():
if obj.organization and self.user in obj.organization.deprecated_admins.all():
return True
return False

View File

@ -198,7 +198,8 @@ def migrate_credential(apps, schema_editor):
logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at organization level".format(cred.name, cred.kind, cred.host)))
if cred.deprecated_team is not None:
cred.deprecated_team.member_role.children.add(cred.admin_role)
cred.deprecated_team.admin_role.children.add(cred.admin_role)
cred.deprecated_team.member_role.children.add(cred.use_role)
cred.save()
logger.info(smart_text(u"added Credential(name={}, kind={}, host={}) at user level".format(cred.name, cred.kind, cred.host)))
elif cred.deprecated_user is not None:

View File

@ -0,0 +1,30 @@
# Python
import logging
from django.utils.encoding import smart_text
logger = logging.getLogger(__name__)
def log_migration(wrapped):
'''setup the logging mechanism for each migration method
as it runs, Django resets this, so we use a decorator
to re-add the handler for each method.
'''
handler = logging.FileHandler("/tmp/tower_rbac_migrations.log", mode="a", encoding="UTF-8")
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setLevel(logging.DEBUG)
handler.setFormatter(formatter)
def wrapper(*args, **kwargs):
logger.handlers = []
logger.addHandler(handler)
return wrapped(*args, **kwargs)
return wrapper
@log_migration
def migrate_team(apps, schema_editor):
'''If an orphan team exists that is still active, delete it.'''
Team = apps.get_model('main', 'Team')
for team in Team.objects.iterator():
if team.organization is None:
logger.info(smart_text(u"Deleting orphaned team: {}".format(team.name)))
team.delete()

View File

@ -309,8 +309,7 @@ class Inventory(CommonModel, ResourceMixin):
else:
computed_fields.pop(field)
if computed_fields:
if len(computed_fields) > 0:
iobj.save(update_fields=computed_fields.keys())
iobj.save(update_fields=computed_fields.keys())
logger.debug("Finished updating inventory computed fields")
@property

View File

@ -681,10 +681,10 @@ class Job(UnifiedJob, JobOptions):
ok=h.ok,
processed=h.processed,
skipped=h.skipped)
data.update(dict(inventory=self.inventory.name,
data.update(dict(inventory=self.inventory.name if self.inventory else None,
project=self.project.name if self.project else None,
playbook=self.playbook,
credential=self.credential.name,
credential=self.credential.name if self.credential else None,
limit=self.limit,
extra_vars=self.extra_vars,
hosts=all_hosts))

View File

@ -92,8 +92,8 @@ class Team(CommonModelNameNotUnique, ResourceMixin):
organization = models.ForeignKey(
'Organization',
blank=False,
null=True,
on_delete=models.SET_NULL,
null=False,
on_delete=models.CASCADE,
related_name='teams',
)
deprecated_projects = models.ManyToManyField(

View File

@ -39,6 +39,7 @@ __all__ = ['UnifiedJobTemplate', 'UnifiedJob']
logger = logging.getLogger('awx.main.models.unified_jobs')
CAN_CANCEL = ('new', 'pending', 'waiting', 'running')
ACTIVE_STATES = CAN_CANCEL
class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, NotificationFieldsModel):

View File

@ -323,6 +323,7 @@ model_serializer_mapping = {
Host: HostSerializer,
Group: GroupSerializer,
InventorySource: InventorySourceSerializer,
CustomInventoryScript: CustomInventoryScriptSerializer,
Credential: CredentialSerializer,
Team: TeamSerializer,
Project: ProjectSerializer,
@ -410,7 +411,10 @@ def activity_stream_associate(sender, instance, **kwargs):
for entity_acted in kwargs['pk_set']:
obj2 = kwargs['model']
obj2_id = entity_acted
obj2_actual = obj2.objects.get(id=obj2_id)
obj2_actual = obj2.objects.filter(id=obj2_id)
if not obj2_actual.exists():
continue
obj2_actual = obj2_actual[0]
if isinstance(obj2_actual, Role) and obj2_actual.content_object is not None:
obj2_actual = obj2_actual.content_object
object2 = camelcase_to_underscore(obj2_actual.__class__.__name__)

View File

@ -599,7 +599,8 @@ class BaseTask(Task):
else:
child_procs = main_proc.get_children(recursive=True)
for child_proc in child_procs:
os.kill(child_proc.pid, signal.SIGTERM)
os.kill(child_proc.pid, signal.SIGKILL)
os.kill(main_proc.pid, signal.SIGKILL)
except TypeError:
os.kill(child.pid, signal.SIGKILL)
else:

43
awx/main/tests/URI.py Normal file
View File

@ -0,0 +1,43 @@
# Helps with test cases.
# Save all components of a uri (i.e. scheme, username, password, etc.) so that
# when we construct a uri string and decompose it, we can verify the decomposition
class URI(object):
DEFAULTS = {
'scheme' : 'http',
'username' : 'MYUSERNAME',
'password' : 'MYPASSWORD',
'host' : 'host.com',
}
def __init__(self, description='N/A', scheme=DEFAULTS['scheme'], username=DEFAULTS['username'], password=DEFAULTS['password'], host=DEFAULTS['host']):
self.description = description
self.scheme = scheme
self.username = username
self.password = password
self.host = host
def get_uri(self):
uri = "%s://" % self.scheme
if self.username:
uri += "%s" % self.username
if self.password:
uri += ":%s" % self.password
if (self.username or self.password) and self.host is not None:
uri += "@%s" % self.host
elif self.host is not None:
uri += "%s" % self.host
return uri
def get_secret_count(self):
secret_count = 0
if self.username:
secret_count += 1
if self.password:
secret_count += 1
return secret_count
def __string__(self):
return self.get_uri()
def __repr__(self):
return self.get_uri()

View File

@ -35,6 +35,7 @@ from awx.main.management.commands.run_task_system import run_taskmanager
from awx.main.utils import get_ansible_version
from awx.main.task_engine import TaskEngager as LicenseWriter
from awx.sso.backends import LDAPSettings
from awx.main.tests.URI import URI # noqa
TEST_PLAYBOOK = '''- hosts: mygroup
gather_facts: false
@ -732,48 +733,3 @@ class BaseJobExecutionTest(QueueStartStopTestMixin, BaseLiveServerTest):
'''
Base class for celery task tests.
'''
# Helps with test cases.
# Save all components of a uri (i.e. scheme, username, password, etc.) so that
# when we construct a uri string and decompose it, we can verify the decomposition
class URI(object):
DEFAULTS = {
'scheme' : 'http',
'username' : 'MYUSERNAME',
'password' : 'MYPASSWORD',
'host' : 'host.com',
}
def __init__(self, description='N/A', scheme=DEFAULTS['scheme'], username=DEFAULTS['username'], password=DEFAULTS['password'], host=DEFAULTS['host']):
self.description = description
self.scheme = scheme
self.username = username
self.password = password
self.host = host
def get_uri(self):
uri = "%s://" % self.scheme
if self.username:
uri += "%s" % self.username
if self.password:
uri += ":%s" % self.password
if (self.username or self.password) and self.host is not None:
uri += "@%s" % self.host
elif self.host is not None:
uri += "%s" % self.host
return uri
def get_secret_count(self):
secret_count = 0
if self.username:
secret_count += 1
if self.password:
secret_count += 1
return secret_count
def __string__(self):
return self.get_uri()
def __repr__(self):
return self.get_uri()

View File

@ -122,7 +122,7 @@ def test_get_inventory_ad_hoc_command_list(admin, alice, post_adhoc, get, invent
inv1.adhoc_role.members.add(alice)
res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv1.id,)), alice, expect=200)
assert res.data['count'] == 0
assert res.data['count'] == 1
machine_credential.use_role.members.add(alice)
res = get(reverse('api:inventory_ad_hoc_commands_list', args=(inv1.id,)), alice, expect=200)

View File

@ -0,0 +1,190 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Python
import pytest
import mock
# Django
from django.core.urlresolvers import reverse
# AWX
from awx.main.models import * # noqa
@pytest.mark.django_db
def test_organization_list_access_tests(options, head, get, admin, alice):
options(reverse('api:organization_list'), user=admin, expect=200)
head(reverse('api:organization_list'), user=admin, expect=200)
get(reverse('api:organization_list'), user=admin, expect=200)
options(reverse('api:organization_list'), user=alice, expect=200)
head(reverse('api:organization_list'), user=alice, expect=200)
get(reverse('api:organization_list'), user=alice, expect=200)
options(reverse('api:organization_list'), user=None, expect=401)
head(reverse('api:organization_list'), user=None, expect=401)
get(reverse('api:organization_list'), user=None, expect=401)
@pytest.mark.django_db
def test_organization_access_tests(organization, get, admin, alice, bob):
organization.member_role.members.add(alice)
get(reverse('api:organization_detail', args=(organization.id,)), user=admin, expect=200)
get(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=200)
get(reverse('api:organization_detail', args=(organization.id,)), user=bob, expect=403)
get(reverse('api:organization_detail', args=(organization.id,)), user=None, expect=401)
@pytest.mark.django_db
def test_organization_list_integrity(organization, get, admin, alice):
res = get(reverse('api:organization_list'), user=admin)
for field in ['id', 'url', 'name', 'description', 'created']:
assert field in res.data['results'][0]
@pytest.mark.django_db
def test_organization_list_visibility(organizations, get, admin, alice):
orgs = organizations(2)
res = get(reverse('api:organization_list'), user=admin)
assert res.data['count'] == 2
assert len(res.data['results']) == 2
res = get(reverse('api:organization_list'), user=alice)
assert res.data['count'] == 0
orgs[1].member_role.members.add(alice)
res = get(reverse('api:organization_list'), user=alice)
assert res.data['count'] == 1
assert len(res.data['results']) == 1
assert res.data['results'][0]['id'] == orgs[1].id
@pytest.mark.django_db
def test_organization_project_list(organization, project_factory, get, alice, bob, rando):
prj1 = project_factory('project-one')
project_factory('project-two')
organization.admin_role.members.add(alice)
organization.member_role.members.add(bob)
prj1.use_role.members.add(bob)
assert get(reverse('api:organization_projects_list', args=(organization.id,)), user=alice).data['count'] == 2
assert get(reverse('api:organization_projects_list', args=(organization.id,)), user=bob).data['count'] == 1
assert get(reverse('api:organization_projects_list', args=(organization.id,)), user=rando).status_code == 403
@pytest.mark.django_db
def test_organization_user_list(organization, get, admin, alice, bob):
organization.admin_role.members.add(alice)
organization.member_role.members.add(alice)
organization.member_role.members.add(bob)
assert get(reverse('api:organization_users_list', args=(organization.id,)), user=admin).data['count'] == 2
assert get(reverse('api:organization_users_list', args=(organization.id,)), user=alice).data['count'] == 2
assert get(reverse('api:organization_users_list', args=(organization.id,)), user=bob).data['count'] == 2
assert get(reverse('api:organization_admins_list', args=(organization.id,)), user=admin).data['count'] == 1
assert get(reverse('api:organization_admins_list', args=(organization.id,)), user=alice).data['count'] == 1
assert get(reverse('api:organization_admins_list', args=(organization.id,)), user=bob).data['count'] == 1
@pytest.mark.django_db
def test_organization_inventory_list(organization, inventory_factory, get, alice, bob, rando):
inv1 = inventory_factory('inventory-one')
inventory_factory('inventory-two')
organization.admin_role.members.add(alice)
organization.member_role.members.add(bob)
inv1.use_role.members.add(bob)
assert get(reverse('api:organization_inventories_list', args=(organization.id,)), user=alice).data['count'] == 2
assert get(reverse('api:organization_inventories_list', args=(organization.id,)), user=bob).data['count'] == 1
get(reverse('api:organization_inventories_list', args=(organization.id,)), user=rando, expect=403)
@pytest.mark.django_db
@mock.patch('awx.api.views.feature_enabled', lambda feature,bypass_db=None: True)
def test_create_organization(post, admin, alice):
new_org = {
'name': 'new org',
'description': 'my description'
}
res = post(reverse('api:organization_list'), new_org, user=admin, expect=201)
assert res.data['name'] == new_org['name']
res = post(reverse('api:organization_list'), new_org, user=admin, expect=400)
@pytest.mark.django_db
@mock.patch('awx.api.views.feature_enabled', lambda feature,bypass_db=None: True)
def test_create_organization_xfail(post, alice):
new_org = {
'name': 'new org',
'description': 'my description'
}
post(reverse('api:organization_list'), new_org, user=alice, expect=403)
@pytest.mark.django_db
def test_add_user_to_organization(post, organization, alice, bob):
organization.admin_role.members.add(alice)
post(reverse('api:organization_users_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=204)
assert bob in organization.member_role
post(reverse('api:organization_users_list', args=(organization.id,)), {'id': bob.id, 'disassociate': True} , user=alice, expect=204)
assert bob not in organization.member_role
@pytest.mark.django_db
def test_add_user_to_organization_xfail(post, organization, alice, bob):
organization.member_role.members.add(alice)
post(reverse('api:organization_users_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=403)
@pytest.mark.django_db
def test_add_admin_to_organization(post, organization, alice, bob):
organization.admin_role.members.add(alice)
post(reverse('api:organization_admins_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=204)
assert bob in organization.admin_role
assert bob in organization.member_role
post(reverse('api:organization_admins_list', args=(organization.id,)), {'id': bob.id, 'disassociate': True} , user=alice, expect=204)
assert bob not in organization.admin_role
assert bob not in organization.member_role
@pytest.mark.django_db
def test_add_admin_to_organization_xfail(post, organization, alice, bob):
organization.member_role.members.add(alice)
post(reverse('api:organization_admins_list', args=(organization.id,)), {'id': bob.id}, user=alice, expect=403)
@pytest.mark.django_db
def test_update_organization(get, put, organization, alice, bob):
organization.admin_role.members.add(alice)
data = get(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=200).data
data['description'] = 'hi'
put(reverse('api:organization_detail', args=(organization.id,)), data, user=alice, expect=200)
organization.refresh_from_db()
assert organization.description == 'hi'
data['description'] = 'bye'
put(reverse('api:organization_detail', args=(organization.id,)), data, user=bob, expect=403)
@pytest.mark.django_db
@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True)
def test_delete_organization(delete, organization, admin):
delete(reverse('api:organization_detail', args=(organization.id,)), user=admin, expect=204)
@pytest.mark.django_db
@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True)
def test_delete_organization2(delete, organization, alice):
organization.admin_role.members.add(alice)
delete(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=204)
@pytest.mark.django_db
@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True)
def test_delete_organization_xfail1(delete, organization, alice):
organization.member_role.members.add(alice)
delete(reverse('api:organization_detail', args=(organization.id,)), user=alice, expect=403)
@pytest.mark.django_db
@mock.patch('awx.main.access.BaseAccess.check_license', lambda *a, **kw: True)
def test_delete_organization_xfail2(delete, organization):
delete(reverse('api:organization_detail', args=(organization.id,)), user=None, expect=401)

View File

@ -1,8 +1,83 @@
import pytest
from awx.main.models import UnifiedJob
from django.core.urlresolvers import reverse
from awx.main.models import UnifiedJob, ProjectUpdate
from awx.main.tests.base import URI
TEST_STDOUTS = []
uri = URI(scheme="https", username="Dhh3U47nmC26xk9PKscV", password="PXPfWW8YzYrgS@E5NbQ2H@", host="github.ginger.com/theirrepo.git/info/refs")
TEST_STDOUTS.append({
'description': 'uri in a plain text document',
'uri' : uri,
'text' : 'hello world %s goodbye world' % uri,
'occurrences' : 1
})
uri = URI(scheme="https", username="applepie@@@", password="thatyouknow@@@@", host="github.ginger.com/theirrepo.git/info/refs")
TEST_STDOUTS.append({
'description': 'uri appears twice in a multiline plain text document',
'uri' : uri,
'text' : 'hello world %s \n\nyoyo\n\nhello\n%s' % (uri, uri),
'occurrences' : 2
})
@pytest.fixture
def test_cases(project):
ret = []
for e in TEST_STDOUTS:
e['project'] = ProjectUpdate(project=project)
e['project'].result_stdout_text = e['text']
e['project'].save()
ret.append(e)
return ret
@pytest.fixture
def negative_test_cases(job_factory):
ret = []
for e in TEST_STDOUTS:
e['job'] = job_factory()
e['job'].result_stdout_text = e['text']
e['job'].save()
ret.append(e)
return ret
formats = [
('json', 'application/json'),
('ansi', 'text/plain'),
('txt', 'text/plain'),
('html', 'text/html'),
]
@pytest.mark.parametrize("format,content_type", formats)
@pytest.mark.django_db
def test_project_update_redaction_enabled(get, format, content_type, test_cases, admin):
for test_data in test_cases:
job = test_data['project']
response = get(reverse("api:project_update_stdout", args=(job.pk,)) + "?format=" + format, user=admin, expect=200, accept=content_type)
assert content_type in response['CONTENT-TYPE']
assert response.data is not None
content = response.data['content'] if format == 'json' else response.data
assert test_data['uri'].username not in content
assert test_data['uri'].password not in content
assert content.count(test_data['uri'].host) == test_data['occurrences']
@pytest.mark.parametrize("format,content_type", formats)
@pytest.mark.django_db
def test_job_redaction_disabled(get, format, content_type, negative_test_cases, admin):
for test_data in negative_test_cases:
job = test_data['job']
response = get(reverse("api:job_stdout", args=(job.pk,)) + "?format=" + format, user=admin, expect=200, format=format)
content = response.data['content'] if format == 'json' else response.data
assert response.data is not None
assert test_data['uri'].username in content
assert test_data['uri'].password in content
@pytest.mark.django_db
def test_options_fields_choices(instance, options, user):
@ -15,3 +90,4 @@ def test_options_fields_choices(instance, options, user):
assert 'choice' == response.data['actions']['GET']['status']['type']
assert UnifiedJob.STATUS_CHOICES == response.data['actions']['GET']['status']['choices']

View File

@ -38,7 +38,10 @@ from awx.main.models.organization import (
Team,
)
from awx.main.models.notifications import NotificationTemplate
from awx.main.models.notifications import (
NotificationTemplate,
Notification
)
'''
Disable all django model signals.
@ -124,6 +127,12 @@ def project_factory(organization):
return prj
return factory
@pytest.fixture
def job_factory(job_template, admin):
def factory(job_template=job_template, initial_state='new', created_by=admin):
return job_template.create_job(created_by=created_by, status=initial_state)
return factory
@pytest.fixture
def team_factory(organization):
def factory(name):
@ -187,6 +196,15 @@ def notification_template(organization):
notification_configuration=dict(url="http://localhost",
headers={"Test": "Header"}))
@pytest.fixture
def notification(notification_template):
return Notification.objects.create(notification_template=notification_template,
status='successful',
notifications_sent=1,
notification_type='email',
recipients='admin@redhat.com',
subject='email subject')
@pytest.fixture
def job_with_secret_key(job_with_secret_key_factory):
return job_with_secret_key_factory(persisted=True)
@ -292,17 +310,23 @@ def permissions():
'update':False, 'delete':False, 'scm_update':False, 'execute':False, 'use':True,},
}
@pytest.fixture
def post():
def rf(url, data, user=None, middleware=None, expect=None, **kwargs):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
def _request(verb):
def rf(url, data_or_user=None, user=None, middleware=None, expect=None, **kwargs):
if type(data_or_user) is User and user is None:
user = data_or_user
elif 'data' not in kwargs:
kwargs['data'] = data_or_user
if 'format' not in kwargs:
kwargs['format'] = 'json'
request = APIRequestFactory().post(url, data, **kwargs)
view, view_args, view_kwargs = resolve(urlparse(url)[2])
request = getattr(APIRequestFactory(), verb)(url, **kwargs)
if middleware:
middleware.process_request(request)
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if middleware:
middleware.process_response(request, response)
@ -310,134 +334,37 @@ def post():
if response.status_code != expect:
print(response.data)
assert response.status_code == expect
response.render()
return response
return rf
@pytest.fixture
def post():
return _request('post')
@pytest.fixture
def get():
def rf(url, user=None, middleware=None, expect=None, **kwargs):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
if 'format' not in kwargs:
kwargs['format'] = 'json'
request = APIRequestFactory().get(url, **kwargs)
if middleware:
middleware.process_request(request)
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if middleware:
middleware.process_response(request, response)
if expect:
if response.status_code != expect:
print(response.data)
assert response.status_code == expect
return response
return rf
return _request('get')
@pytest.fixture
def put():
def rf(url, data, user=None, middleware=None, expect=None, **kwargs):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
if 'format' not in kwargs:
kwargs['format'] = 'json'
request = APIRequestFactory().put(url, data, **kwargs)
if middleware:
middleware.process_request(request)
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if middleware:
middleware.process_response(request, response)
if expect:
if response.status_code != expect:
print(response.data)
assert response.status_code == expect
return response
return rf
return _request('put')
@pytest.fixture
def patch():
def rf(url, data, user=None, middleware=None, expect=None, **kwargs):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
if 'format' not in kwargs:
kwargs['format'] = 'json'
request = APIRequestFactory().patch(url, data, **kwargs)
if middleware:
middleware.process_request(request)
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if middleware:
middleware.process_response(request, response)
if expect:
if response.status_code != expect:
print(response.data)
assert response.status_code == expect
return response
return rf
return _request('patch')
@pytest.fixture
def delete():
def rf(url, user=None, middleware=None, expect=None, **kwargs):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
if 'format' not in kwargs:
kwargs['format'] = 'json'
request = APIRequestFactory().delete(url, **kwargs)
if middleware:
middleware.process_request(request)
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if middleware:
middleware.process_response(request, response)
if expect:
if response.status_code != expect:
print(response.data)
assert response.status_code == expect
return response
return rf
return _request('delete')
@pytest.fixture
def head():
def rf(url, user=None, middleware=None, expect=None, **kwargs):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
if 'format' not in kwargs:
kwargs['format'] = 'json'
request = APIRequestFactory().head(url, **kwargs)
if middleware:
middleware.process_request(request)
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if middleware:
middleware.process_response(request, response)
if expect:
if response.status_code != expect:
print(response.data)
assert response.status_code == expect
return response
return rf
return _request('head')
@pytest.fixture
def options():
def rf(url, data, user=None, middleware=None, expect=None, **kwargs):
view, view_args, view_kwargs = resolve(urlparse(url)[2])
if 'format' not in kwargs:
kwargs['format'] = 'json'
request = APIRequestFactory().options(url, data, **kwargs)
if middleware:
middleware.process_request(request)
if user:
force_authenticate(request, user=user)
response = view(request, *view_args, **view_kwargs)
if middleware:
middleware.process_response(request, response)
if expect:
if response.status_code != expect:
print(response.data)
assert response.status_code == expect
return response
return rf
return _request('options')

View File

@ -0,0 +1,132 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import json
import mock
import os
import tempfile
import time
import pytest
from datetime import datetime
from awx.main.models import Host
from awx.main.task_engine import TaskSerializer, TaskEngager
@pytest.mark.django_db
def test_license_writer(inventory, admin):
writer = TaskEngager(
company_name='acmecorp',
contact_name='Michael DeHaan',
contact_email='michael@ansibleworks.com',
license_date=25000, # seconds since epoch
instance_count=500)
data = writer.get_data()
Host.objects.bulk_create(
[
Host(
name='host.%d' % n,
inventory=inventory,
created_by=admin,
modified=datetime.now(),
created=datetime.now())
for n in range(12)
]
)
assert data['instance_count'] == 500
assert data['contact_name'] == 'Michael DeHaan'
assert data['contact_email'] == 'michael@ansibleworks.com'
assert data['license_date'] == 25000
assert data['license_key'] == "11bae31f31c6a6cdcb483a278cdbe98bd8ac5761acd7163a50090b0f098b3a13"
strdata = writer.get_string()
strdata_loaded = json.loads(strdata)
assert strdata_loaded == data
reader = TaskSerializer()
vdata = reader.from_string(strdata)
assert vdata['available_instances'] == 500
assert vdata['current_instances'] == 12
assert vdata['free_instances'] == 488
assert vdata['date_warning'] is True
assert vdata['date_expired'] is True
assert vdata['license_date'] == 25000
assert vdata['time_remaining'] < 0
assert vdata['valid_key'] is True
assert vdata['compliant'] is False
assert vdata['subscription_name']
@pytest.mark.django_db
def test_expired_licenses():
reader = TaskSerializer()
writer = TaskEngager(
company_name='Tower',
contact_name='Tower Admin',
contact_email='tower@ansible.com',
license_date=int(time.time() - 3600),
instance_count=100,
trial=True)
strdata = writer.get_string()
vdata = reader.from_string(strdata)
assert vdata['compliant'] is False
assert vdata['grace_period_remaining'] < 0
writer = TaskEngager(
company_name='Tower',
contact_name='Tower Admin',
contact_email='tower@ansible.com',
license_date=int(time.time() - 2592001),
instance_count=100,
trial=False)
strdata = writer.get_string()
vdata = reader.from_string(strdata)
assert vdata['compliant'] is False
assert vdata['grace_period_remaining'] < 0
writer = TaskEngager(
company_name='Tower',
contact_name='Tower Admin',
contact_email='tower@ansible.com',
license_date=int(time.time() - 3600),
instance_count=100,
trial=False)
strdata = writer.get_string()
vdata = reader.from_string(strdata)
assert vdata['compliant'] is False
assert vdata['grace_period_remaining'] > 0
@pytest.mark.django_db
def test_aws_license():
os.environ['AWX_LICENSE_FILE'] = 'non-existent-license-file.json'
h, path = tempfile.mkstemp()
with os.fdopen(h, 'w') as f:
json.dump({'instance_count': 100}, f)
def fetch_ami(_self):
_self.attributes['ami-id'] = 'ami-00000000'
return True
def fetch_instance(_self):
_self.attributes['instance-id'] = 'i-00000000'
return True
with mock.patch('awx.main.task_engine.TEMPORARY_TASK_FILE', path):
with mock.patch('awx.main.task_engine.TemporaryTaskEngine.fetch_ami', fetch_ami):
with mock.patch('awx.main.task_engine.TemporaryTaskEngine.fetch_instance', fetch_instance):
reader = TaskSerializer()
license = reader.from_file()
assert license['is_aws']
assert license['time_remaining']
assert license['free_instances'] > 0
assert license['grace_period_remaining'] > 0
os.unlink(path)

View File

@ -0,0 +1,39 @@
import pytest
from datetime import timedelta
from django.utils.timezone import now as tz_now
from django.test.utils import override_settings
from awx.main.models import AuthToken, User
@override_settings(AUTH_TOKEN_PER_USER=3)
@pytest.mark.django_db
def test_get_tokens_over_limit():
now = tz_now()
# Times are relative to now
# (key, created on in seconds , expiration in seconds)
test_data = [
# a is implicitly expired
("a", -1000, -10),
# b's are invalid due to session limit of 3
("b", -100, 60),
("bb", -100, 60),
("c", -90, 70),
("d", -80, 80),
("e", -70, 90),
]
user = User.objects.create_superuser('admin', 'foo@bar.com', 'password')
for key, t_create, t_expire in test_data:
AuthToken.objects.create(
user=user,
key=key,
request_hash='this_is_a_hash',
created=now + timedelta(seconds=t_create),
expires=now + timedelta(seconds=t_expire),
)
invalid_tokens = AuthToken.get_tokens_over_limit(user, now=now)
invalid_keys = [x.key for x in invalid_tokens]
assert len(invalid_keys) == 2
assert 'b' in invalid_keys
assert 'bb' in invalid_keys

View File

@ -114,3 +114,19 @@ def test_create_project(post, organization, org_admin, org_member, admin, rando,
assert result.status_code == expected_status_code
if expected_status_code == 201:
assert Project.objects.filter(name='Project', organization=organization).exists()
@pytest.mark.django_db()
def test_create_project_null_organization(post, organization, admin):
post(reverse('api:project_list'), { 'name': 't', 'organization': None}, admin, expect=201)
@pytest.mark.django_db()
def test_create_project_null_organization_xfail(post, organization, org_admin):
post(reverse('api:project_list'), { 'name': 't', 'organization': None}, org_admin, expect=400)
@pytest.mark.django_db()
def test_patch_project_null_organization(patch, organization, project, admin):
patch(reverse('api:project_detail', args=(project.id,)), { 'name': 't', 'organization': organization.id}, admin, expect=200)
@pytest.mark.django_db()
def test_patch_project_null_organization_xfail(patch, project, org_admin):
patch(reverse('api:project_detail', args=(project.id,)), { 'name': 't', 'organization': None}, org_admin, expect=400)

View File

@ -28,8 +28,10 @@ def test_two_teams_same_cred_name(organization_factory):
rbac.migrate_credential(apps, None)
assert objects.teams.team1.member_role in cred1.admin_role.parents.all()
assert objects.teams.team2.member_role in cred2.admin_role.parents.all()
assert objects.teams.team1.admin_role in cred1.admin_role.parents.all()
assert objects.teams.team2.admin_role in cred2.admin_role.parents.all()
assert objects.teams.team1.member_role in cred1.use_role.parents.all()
assert objects.teams.team2.member_role in cred2.use_role.parents.all()
@pytest.mark.django_db
def test_credential_use_role(credential, user, permissions):
@ -53,7 +55,7 @@ def test_credential_migration_team_member(credential, team, user, permissions):
rbac.migrate_credential(apps, None)
# Admin permissions post migration
assert u in credential.admin_role
assert u in credential.use_role
@pytest.mark.django_db
def test_credential_migration_team_admin(credential, team, user, permissions):

View File

@ -1,6 +1,9 @@
import pytest
from awx.main.access import NotificationTemplateAccess
from awx.main.access import (
NotificationTemplateAccess,
NotificationAccess
)
@pytest.mark.django_db
def test_notification_template_get_queryset_orgmember(notification_template, user):
@ -24,6 +27,11 @@ def test_notification_template_get_queryset_orgadmin(notification_template, user
notification_template.organization.admin_role.members.add(user('admin', False))
assert access.get_queryset().count() == 1
@pytest.mark.django_db
def test_notification_template_get_queryset_org_auditor(notification_template, org_auditor):
access = NotificationTemplateAccess(org_auditor)
assert access.get_queryset().count() == 1
@pytest.mark.django_db
def test_notification_template_access_superuser(notification_template_factory):
nf_objects = notification_template_factory('test-orphaned', organization='test', superusers=['admin'])
@ -81,3 +89,31 @@ def test_notificaiton_template_orphan_access_org_admin(notification_template, or
notification_template.organization = None
access = NotificationTemplateAccess(org_admin)
assert not access.can_change(notification_template, {'organization': organization.id})
@pytest.mark.django_db
def test_notification_access_get_queryset_org_admin(notification, org_admin):
access = NotificationAccess(org_admin)
assert access.get_queryset().count() == 1
@pytest.mark.django_db
def test_notification_access_get_queryset_org_auditor(notification, org_auditor):
access = NotificationAccess(org_auditor)
assert access.get_queryset().count() == 1
@pytest.mark.django_db
def test_notification_access_system_admin(notification, admin):
access = NotificationAccess(admin)
assert access.can_read(notification)
assert access.can_delete(notification)
@pytest.mark.django_db
def test_notification_access_org_admin(notification, org_admin):
access = NotificationAccess(org_admin)
assert access.can_read(notification)
assert access.can_delete(notification)
@pytest.mark.django_db
def test_notification_access_org_auditor(notification, org_auditor):
access = NotificationAccess(org_auditor)
assert access.can_read(notification)
assert not access.can_delete(notification)

View File

@ -1,6 +1,9 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import os
import unittest2 as unittest
from django.conf import settings
from django.test import LiveServerTestCase
from django.test.utils import override_settings
@ -8,6 +11,7 @@ from django.test.utils import override_settings
from awx.main.tests.job_base import BaseJobTestMixin
@unittest.skipIf(os.environ.get('SKIP_SLOW_TESTS', False), 'Skipping slow test')
@override_settings(CELERY_ALWAYS_EAGER=True,
CELERY_EAGER_PROPAGATES_EXCEPTIONS=True,
ANSIBLE_TRANSPORT='local')

View File

@ -1,24 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# Python
import mock
# Django
from django.test import SimpleTestCase
# AWX
from awx.main.models import * # noqa
from awx.main.ha import * # noqa
__all__ = ['HAUnitTest',]
class HAUnitTest(SimpleTestCase):
@mock.patch('awx.main.models.Instance.objects.count', return_value=2)
def test_multiple_instances(self, ignore):
self.assertTrue(is_ha_environment())
@mock.patch('awx.main.models.Instance.objects.count', return_value=1)
def test_db_localhost(self, ignore):
self.assertFalse(is_ha_environment())

View File

@ -4,6 +4,8 @@
# Python
from __future__ import absolute_import
import json
import os
import unittest2 as unittest
# Django
from django.core.urlresolvers import reverse
@ -15,6 +17,7 @@ from awx.main.tests.job_base import BaseJobTestMixin
__all__ = ['JobRelaunchTest',]
@unittest.skipIf(os.environ.get('SKIP_SLOW_TESTS', False), 'Skipping slow test')
class JobRelaunchTest(BaseJobTestMixin, BaseLiveServerTest):
def test_job_relaunch(self):

View File

@ -1,147 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
import json
import os
import tempfile
from awx.main.models import Host, Inventory, Organization
from awx.main.tests.base import BaseTest
import awx.main.task_engine
from awx.main.task_engine import * # noqa
class LicenseTests(BaseTest):
def setUp(self):
self.start_redis()
self.setup_instances()
super(LicenseTests, self).setUp()
self.setup_users()
u = self.super_django_user
org = Organization.objects.create(name='o1', created_by=u)
org.admin_role.members.add(self.normal_django_user)
self.inventory = Inventory.objects.create(name='hi', organization=org, created_by=u)
Host.objects.create(name='a1', inventory=self.inventory, created_by=u)
Host.objects.create(name='a2', inventory=self.inventory, created_by=u)
Host.objects.create(name='a3', inventory=self.inventory, created_by=u)
Host.objects.create(name='a4', inventory=self.inventory, created_by=u)
Host.objects.create(name='a5', inventory=self.inventory, created_by=u)
Host.objects.create(name='a6', inventory=self.inventory, created_by=u)
Host.objects.create(name='a7', inventory=self.inventory, created_by=u)
Host.objects.create(name='a8', inventory=self.inventory, created_by=u)
Host.objects.create(name='a9', inventory=self.inventory, created_by=u)
Host.objects.create(name='a10', inventory=self.inventory, created_by=u)
Host.objects.create(name='a11', inventory=self.inventory, created_by=u)
Host.objects.create(name='a12', inventory=self.inventory, created_by=u)
self._temp_task_file = awx.main.task_engine.TEMPORARY_TASK_FILE
self._temp_task_fetch_ami = awx.main.task_engine.TemporaryTaskEngine.fetch_ami
self._temp_task_fetch_instance = awx.main.task_engine.TemporaryTaskEngine.fetch_instance
def tearDown(self):
awx.main.task_engine.TEMPORARY_TASK_FILE = self._temp_task_file
awx.main.task_engine.TemporaryTaskEngine.fetch_ami = self._temp_task_fetch_ami
awx.main.task_engine.TemporaryTaskEngine.fetch_instance = self._temp_task_fetch_instance
super(LicenseTests, self).tearDown()
self.stop_redis()
def test_license_writer(self):
writer = TaskEngager(
company_name='acmecorp',
contact_name='Michael DeHaan',
contact_email='michael@ansibleworks.com',
license_date=25000, # seconds since epoch
instance_count=500)
data = writer.get_data()
assert data['instance_count'] == 500
assert data['contact_name'] == 'Michael DeHaan'
assert data['contact_email'] == 'michael@ansibleworks.com'
assert data['license_date'] == 25000
assert data['license_key'] == "11bae31f31c6a6cdcb483a278cdbe98bd8ac5761acd7163a50090b0f098b3a13"
strdata = writer.get_string()
strdata_loaded = json.loads(strdata)
assert strdata_loaded == data
reader = TaskSerializer()
vdata = reader.from_string(strdata)
assert vdata['available_instances'] == 500
assert vdata['current_instances'] == 12
assert vdata['free_instances'] == 488
assert vdata['date_warning'] is True
assert vdata['date_expired'] is True
assert vdata['license_date'] == 25000
assert vdata['time_remaining'] < 0
assert vdata['valid_key'] is True
assert vdata['compliant'] is False
assert vdata['subscription_name']
def test_expired_licenses(self):
reader = TaskSerializer()
writer = TaskEngager(
company_name='Tower',
contact_name='Tower Admin',
contact_email='tower@ansible.com',
license_date=int(time.time() - 3600),
instance_count=100,
trial=True)
strdata = writer.get_string()
vdata = reader.from_string(strdata)
assert vdata['compliant'] is False
assert vdata['grace_period_remaining'] < 0
writer = TaskEngager(
company_name='Tower',
contact_name='Tower Admin',
contact_email='tower@ansible.com',
license_date=int(time.time() - 2592001),
instance_count=100,
trial=False)
strdata = writer.get_string()
vdata = reader.from_string(strdata)
assert vdata['compliant'] is False
assert vdata['grace_period_remaining'] < 0
writer = TaskEngager(
company_name='Tower',
contact_name='Tower Admin',
contact_email='tower@ansible.com',
license_date=int(time.time() - 3600),
instance_count=100,
trial=False)
strdata = writer.get_string()
vdata = reader.from_string(strdata)
assert vdata['compliant'] is False
assert vdata['grace_period_remaining'] > 0
def test_aws_license(self):
os.environ['AWX_LICENSE_FILE'] = 'non-existent-license-file.json'
h, path = tempfile.mkstemp()
self._temp_paths.append(path)
with os.fdopen(h, 'w') as f:
json.dump({'instance_count': 100}, f)
awx.main.task_engine.TEMPORARY_TASK_FILE = path
def fetch_ami(_self):
_self.attributes['ami-id'] = 'ami-00000000'
return True
def fetch_instance(_self):
_self.attributes['instance-id'] = 'i-00000000'
return True
awx.main.task_engine.TemporaryTaskEngine.fetch_ami = fetch_ami
awx.main.task_engine.TemporaryTaskEngine.fetch_instance = fetch_instance
reader = TaskSerializer()
license = reader.from_file()
self.assertTrue(license['is_aws'])
self.assertTrue(license['time_remaining'])
self.assertTrue(license['free_instances'] > 0)
self.assertTrue(license['grace_period_remaining'] > 0)

View File

@ -1,413 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Python
from datetime import timedelta
# Django
from django.core.urlresolvers import reverse
from django.test.utils import override_settings
from django.contrib.auth.models import User
from django.utils.timezone import now as tz_now
# AWX
from awx.main.models import * # noqa
from awx.main.tests.base import BaseTest
__all__ = ['AuthTokenLimitUnitTest', 'OrganizationsTest']
class AuthTokenLimitUnitTest(BaseTest):
def setUp(self):
self.now = tz_now()
# Times are relative to now
# (key, created on in seconds , expiration in seconds)
self.test_data = [
# a is implicitly expired
("a", -1000, -10),
# b's are invalid due to session limit of 3
("b", -100, 60),
("bb", -100, 60),
("c", -90, 70),
("d", -80, 80),
("e", -70, 90),
]
self.user = User.objects.create_superuser('admin', 'foo@bar.com', 'password')
for key, t_create, t_expire in self.test_data:
AuthToken.objects.create(
user=self.user,
key=key,
request_hash='this_is_a_hash',
created=self.now + timedelta(seconds=t_create),
expires=self.now + timedelta(seconds=t_expire),
)
super(AuthTokenLimitUnitTest, self).setUp()
@override_settings(AUTH_TOKEN_PER_USER=3)
def test_get_tokens_over_limit(self):
invalid_tokens = AuthToken.get_tokens_over_limit(self.user, now=self.now)
invalid_keys = [x.key for x in invalid_tokens]
self.assertEqual(len(invalid_keys), 2)
self.assertIn('b', invalid_keys)
self.assertIn('bb', invalid_keys)
class OrganizationsTest(BaseTest):
def collection(self):
return reverse('api:organization_list')
def setUp(self):
super(OrganizationsTest, self).setUp()
self.setup_instances()
# TODO: Test non-enterprise license
self.create_test_license_file()
self.setup_users()
self.organizations = self.make_organizations(self.super_django_user, 10)
self.projects = self.make_projects(self.normal_django_user, 10)
# add projects to organizations in a more or less arbitrary way
for project in self.projects[0:2]:
self.organizations[0].projects.add(project)
for project in self.projects[3:8]:
self.organizations[1].projects.add(project)
for project in self.projects[9:10]:
self.organizations[2].projects.add(project)
self.organizations[0].projects.add(self.projects[-1])
self.organizations[9].projects.add(self.projects[-2])
# get the URL for various organization records
self.a_detail_url = "%s%s" % (self.collection(), self.organizations[0].pk)
self.b_detail_url = "%s%s" % (self.collection(), self.organizations[1].pk)
self.c_detail_url = "%s%s" % (self.collection(), self.organizations[2].pk)
# configuration:
# admin_user is an admin and regular user in all organizations
# other_user is all organizations
# normal_user is a user in organization 0, and an admin of organization 1
# nobody_user is a user not a member of any organizations
for x in self.organizations:
x.admin_role.members.add(self.super_django_user)
x.member_role.members.add(self.super_django_user)
x.member_role.members.add(self.other_django_user)
self.organizations[0].member_role.members.add(self.normal_django_user)
self.organizations[1].admin_role.members.add(self.normal_django_user)
def test_get_organization_list(self):
url = reverse('api:organization_list')
# no credentials == 401
self.options(url, expect=401)
self.head(url, expect=401)
self.get(url, expect=401)
# wrong credentials == 401
with self.current_user(self.get_invalid_credentials()):
self.options(url, expect=401)
self.head(url, expect=401)
self.get(url, expect=401)
# superuser credentials == 200, full list
with self.current_user(self.super_django_user):
self.options(url, expect=200)
self.head(url, expect=200)
response = self.get(url, expect=200)
self.check_pagination_and_size(response, 10, previous=None, next=None)
self.assertEqual(len(response['results']),
Organization.objects.count())
for field in ['id', 'url', 'name', 'description', 'created']:
self.assertTrue(field in response['results'][0],
'field %s not in result' % field)
# check that the related URL functionality works
related = response['results'][0]['related']
for x in ['projects', 'users', 'admins']:
self.assertTrue(x in related and related[x].endswith("/%s/" % x), "looking for %s in related" % x)
# normal credentials == 200, get only organizations of which user is a member
with self.current_user(self.normal_django_user):
self.options(url, expect=200)
self.head(url, expect=200)
response = self.get(url, expect=200)
self.check_pagination_and_size(response, 2, previous=None, next=None)
# no admin rights? get empty list
with self.current_user(self.other_django_user):
response = self.get(url, expect=200)
self.check_pagination_and_size(response, len(self.organizations), previous=None, next=None)
# not a member of any orgs? get empty list
with self.current_user(self.nobody_django_user):
response = self.get(url, expect=200)
self.check_pagination_and_size(response, 0, previous=None, next=None)
def test_get_item(self):
# first get all the URLs
data = self.get(self.collection(), expect=200, auth=self.get_super_credentials())
urls = [item['url'] for item in data['results']]
# make sure super user can fetch records
data = self.get(urls[0], expect=200, auth=self.get_super_credentials())
[self.assertTrue(key in data) for key in ['name', 'description', 'url']]
# make sure invalid user cannot
data = self.get(urls[0], expect=401, auth=self.get_invalid_credentials())
# normal user should be able to get org 0 and org 1 but not org 9 (as he's not a user or admin of it)
data = self.get(urls[0], expect=200, auth=self.get_normal_credentials())
data = self.get(urls[1], expect=200, auth=self.get_normal_credentials())
data = self.get(urls[9], expect=403, auth=self.get_normal_credentials())
# other user is a member, but not admin, can access org
data = self.get(urls[0], expect=200, auth=self.get_other_credentials())
# nobody user is not a member, cannot access org
data = self.get(urls[0], expect=403, auth=self.get_nobody_credentials())
def test_get_item_subobjects_projects(self):
# first get all the orgs
orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials())
# find projects attached to the first org
projects0_url = orgs['results'][0]['related']['projects']
projects1_url = orgs['results'][1]['related']['projects']
projects9_url = orgs['results'][9]['related']['projects']
self.get(projects0_url, expect=401, auth=None)
self.get(projects0_url, expect=401, auth=self.get_invalid_credentials())
# normal user is just a member of the first org, so can see all projects under the org
self.get(projects0_url, expect=200, auth=self.get_normal_credentials())
# however in the second org, he's an admin and should see all of them
projects1a = self.get(projects1_url, expect=200, auth=self.get_normal_credentials())
self.assertEquals(projects1a['count'], 5)
# but the non-admin cannot access the list of projects in the org. He should use /projects/ instead!
self.get(projects1_url, expect=200, auth=self.get_other_credentials())
# superuser should be able to read anything
projects9a = self.get(projects9_url, expect=200, auth=self.get_super_credentials())
self.assertEquals(projects9a['count'], 1)
# nobody user is not a member of any org, so can't see projects...
self.get(projects0_url, expect=403, auth=self.get_nobody_credentials())
projects1a = self.get(projects1_url, expect=403, auth=self.get_nobody_credentials())
def test_get_item_subobjects_users(self):
# see if we can list the users added to the organization
orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials())
org1_users_url = orgs['results'][1]['related']['users']
org1_users = self.get(org1_users_url, expect=200, auth=self.get_normal_credentials())
self.assertEquals(org1_users['count'], 2)
org1_users = self.get(org1_users_url, expect=200, auth=self.get_super_credentials())
self.assertEquals(org1_users['count'], 2)
org1_users = self.get(org1_users_url, expect=200, auth=self.get_other_credentials())
self.assertEquals(org1_users['count'], 2)
def test_get_item_subobjects_admins(self):
# see if we can list the users added to the organization
orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials())
org1_users_url = orgs['results'][1]['related']['admins']
org1_users = self.get(org1_users_url, expect=200, auth=self.get_normal_credentials())
self.assertEquals(org1_users['count'], 2)
org1_users = self.get(org1_users_url, expect=200, auth=self.get_super_credentials())
self.assertEquals(org1_users['count'], 2)
def test_get_organization_inventories_list(self):
pass
def _test_get_item_subobjects_tags(self):
# FIXME: Update to support taggit!
# put some tags on the org
org1 = Organization.objects.get(pk=2)
tag1 = Tag.objects.create(name='atag')
tag2 = Tag.objects.create(name='btag')
org1.tags.add(tag1)
org1.tags.add(tag2)
# see if we can list the users added to the organization
orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials())
org1_tags_url = orgs['results'][1]['related']['tags']
org1_tags = self.get(org1_tags_url, expect=200, auth=self.get_normal_credentials())
self.assertEquals(org1_tags['count'], 2)
org1_tags = self.get(org1_tags_url, expect=200, auth=self.get_super_credentials())
self.assertEquals(org1_tags['count'], 2)
org1_tags = self.get(org1_tags_url, expect=403, auth=self.get_other_credentials())
def _test_get_item_subobjects_audit_trail(self):
# FIXME: Update to support whatever audit trail framework is used.
url = '/api/v1/organizations/2/audit_trail/'
self.get(url, expect=200, auth=self.get_normal_credentials())
# FIXME: verify that some audit trail records are auto-created on save AND post
def test_post_item(self):
new_org = dict(name='magic test org', description='8675309')
# need to be a valid user
self.post(self.collection(), new_org, expect=401, auth=None)
self.post(self.collection(), new_org, expect=401, auth=self.get_invalid_credentials())
# only super users can create organizations
self.post(self.collection(), new_org, expect=403, auth=self.get_normal_credentials())
self.post(self.collection(), new_org, expect=403, auth=self.get_other_credentials())
data1 = self.post(self.collection(), new_org, expect=201, auth=self.get_super_credentials())
# duplicate post results in 400
response = self.post(self.collection(), new_org, expect=400, auth=self.get_super_credentials())
self.assertTrue('name' in response, response)
self.assertTrue('Name' in response['name'][0], response)
# look at what we got back from the post, make sure we added an org
last_org = Organization.objects.order_by('-pk')[0]
self.assertTrue(data1['url'].endswith("/%d/" % last_org.pk))
# Test that not even super users can create an organization with a basic license
self.create_basic_license_file()
cant_org = dict(name='silly user org', description='4815162342')
self.post(self.collection(), cant_org, expect=402, auth=self.get_super_credentials())
def test_post_item_subobjects_users(self):
url = reverse('api:organization_users_list', args=(self.organizations[1].pk,))
users = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(users['count'], 2)
self.post(url, dict(id=self.normal_django_user.pk), expect=204, auth=self.get_normal_credentials())
users = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(users['count'], 3)
self.post(url, dict(id=self.normal_django_user.pk, disassociate=True), expect=204, auth=self.get_normal_credentials())
users = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(users['count'], 2)
# post a completely new user to verify we can add users to the subcollection directly
new_user = dict(username='NewUser9000', password='NewPassword9000')
which_org = Organization.accessible_objects(self.normal_django_user, 'admin_role')[0]
url = reverse('api:organization_users_list', args=(which_org.pk,))
self.post(url, new_user, expect=201, auth=self.get_normal_credentials())
all_users = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(all_users['count'], 3)
def test_post_item_subobjects_admins(self):
url = reverse('api:organization_admins_list', args=(self.organizations[1].pk,))
admins = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(admins['count'], 2)
self.post(url, dict(id=self.other_django_user.pk), expect=204, auth=self.get_normal_credentials())
admins = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(admins['count'], 3)
self.post(url, dict(id=self.other_django_user.pk, disassociate=1), expect=204, auth=self.get_normal_credentials())
admins = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(admins['count'], 2)
def _test_post_item_subobjects_tags(self):
# FIXME: Update to support taggit!
tag = Tag.objects.create(name='blippy')
url = '/api/v1/organizations/2/tags/'
tags = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(tags['count'], 0)
self.post(url, dict(id=tag.pk), expect=204, auth=self.get_normal_credentials())
tags = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(tags['count'], 1)
self.assertEqual(tags['results'][0]['id'], tag.pk)
self.post(url, dict(id=tag.pk, disassociate=1), expect=204, auth=self.get_normal_credentials())
tags = self.get(url, expect=200, auth=self.get_normal_credentials())
self.assertEqual(tags['count'], 0)
def _test_post_item_subobjects_audit_trail(self):
# FIXME: Update to support whatever audit trail framework is used.
# audit trails are system things, and no user can post to them.
url = '/api/v1/organizations/2/audit_trail/'
self.post(url, dict(id=1), expect=405, auth=self.get_super_credentials())
def test_put_item(self):
# first get some urls and data to put back to them
urls = self.get_urls(self.collection(), auth=self.get_super_credentials())
self.get(urls[0], expect=200, auth=self.get_super_credentials())
data1 = self.get(urls[1], expect=200, auth=self.get_super_credentials())
# test that an unauthenticated user cannot do a put
new_data1 = data1.copy()
new_data1['description'] = 'updated description'
self.put(urls[0], new_data1, expect=401, auth=None)
self.put(urls[0], new_data1, expect=401, auth=self.get_invalid_credentials())
# user normal is an admin of org 0 and a member of org 1 so should be able to put only org 1
self.put(urls[0], new_data1, expect=403, auth=self.get_normal_credentials())
self.put(urls[1], new_data1, expect=200, auth=self.get_normal_credentials())
# get back org 1 and see if it changed
get_result = self.get(urls[1], expect=200, auth=self.get_normal_credentials())
self.assertEquals(get_result['description'], 'updated description')
# super user can also put even though they aren't added to the org users or admins list
self.put(urls[1], new_data1, expect=200, auth=self.get_super_credentials())
# make sure posting to this URL is not supported
self.post(urls[1], new_data1, expect=405, auth=self.get_super_credentials())
def test_put_item_subobjects_projects(self):
# any attempt to put a subobject should be a 405, edit the actual resource or POST with 'disassociate' to delete
# this is against a collection URL anyway, so we really need not repeat this test for other object types
# as a PUT against a collection doesn't make much sense.
orgs = self.get(self.collection(), expect=200, auth=self.get_super_credentials())
projects0_url = orgs['results'][0]['related']['projects']
sub_projects = self.get(projects0_url, expect=200, auth=self.get_super_credentials())
self.assertEquals(sub_projects['count'], 3)
first_sub_project = sub_projects['results'][0]
self.put(projects0_url, first_sub_project, expect=405, auth=self.get_super_credentials())
def test_delete_item(self):
# first get some urls
urls = self.get_urls(self.collection(), auth=self.get_super_credentials())
urldata1 = self.get(urls[1], auth=self.get_super_credentials())
# check authentication -- admins of the org and superusers can delete objects only
self.delete(urls[0], expect=401, auth=None)
self.delete(urls[0], expect=401, auth=self.get_invalid_credentials())
self.delete(urls[8], expect=403, auth=self.get_normal_credentials())
self.delete(urls[1], expect=204, auth=self.get_normal_credentials())
self.delete(urls[0], expect=204, auth=self.get_super_credentials())
# check that when we have deleted an object it comes back 404 via GET
self.get(urls[1], expect=404, auth=self.get_normal_credentials())
assert Organization.objects.filter(pk=urldata1['id']).count() == 0
# also check that DELETE on the collection doesn't work
self.delete(self.collection(), expect=405, auth=self.get_super_credentials())
# Test that not even super users can delete an organization with a basic license
self.create_basic_license_file()
self.delete(urls[2], expect=402, auth=self.get_super_credentials())
def test_invalid_post_data(self):
url = reverse('api:organization_list')
# API should gracefully handle data of an invalid type.
self.post(url, expect=400, data=None, auth=self.get_super_credentials())
self.post(url, expect=400, data=99, auth=self.get_super_credentials())
self.post(url, expect=400, data='abcd', auth=self.get_super_credentials())
self.post(url, expect=400, data=3.14, auth=self.get_super_credentials())
self.post(url, expect=400, data=True, auth=self.get_super_credentials())
self.post(url, expect=400, data=[1,2,3], auth=self.get_super_credentials())
url = reverse('api:organization_users_list', args=(self.organizations[0].pk,))
self.post(url, expect=400, data=None, auth=self.get_super_credentials())
self.post(url, expect=400, data=99, auth=self.get_super_credentials())
self.post(url, expect=400, data='abcd', auth=self.get_super_credentials())
self.post(url, expect=400, data=3.14, auth=self.get_super_credentials())
self.post(url, expect=400, data=True, auth=self.get_super_credentials())
self.post(url, expect=400, data=[1,2,3], auth=self.get_super_credentials())
# TODO: tests for tag disassociation

View File

@ -1,55 +0,0 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
import mock
from mock import Mock
from StringIO import StringIO
from django.utils.timezone import now
# Django
from django.test import SimpleTestCase
# AWX
from awx.main.models import * # noqa
__all__ = ['UnifiedJobsUnitTest',]
class UnifiedJobsUnitTest(SimpleTestCase):
# stdout file present
@mock.patch('os.path.exists', return_value=True)
@mock.patch('codecs.open', return_value='my_file_handler')
def test_result_stdout_raw_handle_file__found(self, exists, open):
unified_job = UnifiedJob()
unified_job.result_stdout_file = 'dummy'
with mock.patch('os.stat', return_value=Mock(st_size=1)):
result = unified_job.result_stdout_raw_handle()
self.assertEqual(result, 'my_file_handler')
# stdout file missing, job finished
@mock.patch('os.path.exists', return_value=False)
def test_result_stdout_raw_handle__missing(self, exists):
unified_job = UnifiedJob()
unified_job.result_stdout_file = 'dummy'
unified_job.finished = now()
result = unified_job.result_stdout_raw_handle()
self.assertIsInstance(result, StringIO)
self.assertEqual(result.read(), 'stdout capture is missing')
# stdout file missing, job not finished
@mock.patch('os.path.exists', return_value=False)
def test_result_stdout_raw_handle__pending(self, exists):
unified_job = UnifiedJob()
unified_job.result_stdout_file = 'dummy'
unified_job.finished = None
result = unified_job.result_stdout_raw_handle()
self.assertIsInstance(result, StringIO)
self.assertEqual(result.read(), 'Waiting for results...')

View File

@ -1,113 +0,0 @@
# Django
from django.core.urlresolvers import reverse
# Reuse Test code
from awx.main.tests.base import (
BaseLiveServerTest,
QueueStartStopTestMixin,
URI,
)
from awx.main.models.projects import * # noqa
__all__ = ['UnifiedJobStdoutRedactedTests']
TEST_STDOUTS = []
uri = URI(scheme="https", username="Dhh3U47nmC26xk9PKscV", password="PXPfWW8YzYrgS@E5NbQ2H@", host="github.ginger.com/theirrepo.git/info/refs")
TEST_STDOUTS.append({
'description': 'uri in a plain text document',
'uri' : uri,
'text' : 'hello world %s goodbye world' % uri,
'occurrences' : 1
})
uri = URI(scheme="https", username="applepie@@@", password="thatyouknow@@@@", host="github.ginger.com/theirrepo.git/info/refs")
TEST_STDOUTS.append({
'description': 'uri appears twice in a multiline plain text document',
'uri' : uri,
'text' : 'hello world %s \n\nyoyo\n\nhello\n%s' % (uri, uri),
'occurrences' : 2
})
class UnifiedJobStdoutRedactedTests(BaseLiveServerTest, QueueStartStopTestMixin):
def setUp(self):
super(UnifiedJobStdoutRedactedTests, self).setUp()
self.setup_instances()
self.setup_users()
self.test_cases = []
self.negative_test_cases = []
proj = self.make_project()
for e in TEST_STDOUTS:
e['project'] = ProjectUpdate(project=proj)
e['project'].result_stdout_text = e['text']
e['project'].save()
self.test_cases.append(e)
for d in TEST_STDOUTS:
d['job'] = self.make_job()
d['job'].result_stdout_text = d['text']
d['job'].save()
self.negative_test_cases.append(d)
# This is more of a functional test than a unit test.
# should filter out username and password
def check_sensitive_redacted(self, test_data, response):
uri = test_data['uri']
self.assertIsNotNone(response['content'])
self.check_not_found(response['content'], uri.username, test_data['description'])
self.check_not_found(response['content'], uri.password, test_data['description'])
# Ensure the host didn't get redacted
self.check_found(response['content'], uri.host, test_data['occurrences'], test_data['description'])
def check_sensitive_not_redacted(self, test_data, response):
uri = test_data['uri']
self.assertIsNotNone(response['content'])
self.check_found(response['content'], uri.username, description=test_data['description'])
self.check_found(response['content'], uri.password, description=test_data['description'])
def _get_url_job_stdout(self, job, url_base, format='json'):
formats = {
'json': 'application/json',
'ansi': 'text/plain',
'txt': 'text/plain',
'html': 'text/html',
}
content_type = formats[format]
project_update_stdout_url = reverse(url_base, args=(job.pk,)) + "?format=" + format
return self.get(project_update_stdout_url, expect=200, auth=self.get_super_credentials(), accept=content_type)
def _test_redaction_enabled(self, format):
for test_data in self.test_cases:
response = self._get_url_job_stdout(test_data['project'], "api:project_update_stdout", format=format)
self.check_sensitive_redacted(test_data, response)
def _test_redaction_disabled(self, format):
for test_data in self.negative_test_cases:
response = self._get_url_job_stdout(test_data['job'], "api:job_stdout", format=format)
self.check_sensitive_not_redacted(test_data, response)
def test_project_update_redaction_enabled_json(self):
self._test_redaction_enabled('json')
def test_project_update_redaction_enabled_ansi(self):
self._test_redaction_enabled('ansi')
def test_project_update_redaction_enabled_html(self):
self._test_redaction_enabled('html')
def test_project_update_redaction_enabled_txt(self):
self._test_redaction_enabled('txt')
def test_job_redaction_disabled_json(self):
self._test_redaction_disabled('json')
def test_job_redaction_disabled_ansi(self):
self._test_redaction_disabled('ansi')
def test_job_redaction_disabled_html(self):
self._test_redaction_disabled('html')
def test_job_redaction_disabled_txt(self):
self._test_redaction_disabled('txt')

View File

@ -0,0 +1,15 @@
# Copyright (c) 2016 Ansible, Inc.
# Python
import mock
# AWX
from awx.main.ha import is_ha_environment
@mock.patch('awx.main.models.Instance.objects.count', lambda: 2)
def test_multiple_instances():
assert is_ha_environment()
@mock.patch('awx.main.models.Instance.objects.count', lambda: 1)
def test_db_localhost():
assert is_ha_environment() is False

View File

@ -1,11 +1,8 @@
import textwrap
# AWX
from awx.main.redact import UriCleaner
from awx.main.tests.base import BaseTest, URI
__all__ = ['UriCleanTests']
from awx.main.tests.URI import URI
TEST_URIS = [
URI('no host', scheme='https', username='myusername', password='mypass', host=None),
@ -80,59 +77,58 @@ TEST_CLEARTEXT.append({
'host_occurrences' : 4
})
class UriCleanTests(BaseTest):
# should redact sensitive usernames and passwords
def test_uri_scm_simple_redacted(self):
for uri in TEST_URIS:
redacted_str = UriCleaner.remove_sensitive(str(uri))
if uri.username:
self.check_not_found(redacted_str, uri.username, uri.description)
if uri.password:
self.check_not_found(redacted_str, uri.password, uri.description)
# should replace secret data with safe string, UriCleaner.REPLACE_STR
def test_uri_scm_simple_replaced(self):
for uri in TEST_URIS:
redacted_str = UriCleaner.remove_sensitive(str(uri))
self.check_found(redacted_str, UriCleaner.REPLACE_STR, uri.get_secret_count())
# should redact multiple uris in text
def test_uri_scm_multiple(self):
cleartext = ''
for uri in TEST_URIS:
cleartext += str(uri) + ' '
for uri in TEST_URIS:
cleartext += str(uri) + '\n'
# should redact sensitive usernames and passwords
def test_uri_scm_simple_redacted():
for uri in TEST_URIS:
redacted_str = UriCleaner.remove_sensitive(str(uri))
if uri.username:
self.check_not_found(redacted_str, uri.username, uri.description)
assert uri.username not in redacted_str
if uri.password:
self.check_not_found(redacted_str, uri.password, uri.description)
assert uri.username not in redacted_str
# should replace multiple secret data with safe string
def test_uri_scm_multiple_replaced(self):
cleartext = ''
find_count = 0
for uri in TEST_URIS:
cleartext += str(uri) + ' '
find_count += uri.get_secret_count()
# should replace secret data with safe string, UriCleaner.REPLACE_STR
def test_uri_scm_simple_replaced():
for uri in TEST_URIS:
redacted_str = UriCleaner.remove_sensitive(str(uri))
assert redacted_str.count(UriCleaner.REPLACE_STR) == uri.get_secret_count()
for uri in TEST_URIS:
cleartext += str(uri) + '\n'
find_count += uri.get_secret_count()
# should redact multiple uris in text
def test_uri_scm_multiple():
cleartext = ''
for uri in TEST_URIS:
cleartext += str(uri) + ' '
for uri in TEST_URIS:
cleartext += str(uri) + '\n'
redacted_str = UriCleaner.remove_sensitive(cleartext)
self.check_found(redacted_str, UriCleaner.REPLACE_STR, find_count)
redacted_str = UriCleaner.remove_sensitive(str(uri))
if uri.username:
assert uri.username not in redacted_str
if uri.password:
assert uri.username not in redacted_str
# should redact and replace multiple secret data within a complex cleartext blob
def test_uri_scm_cleartext_redact_and_replace(self):
for test_data in TEST_CLEARTEXT:
uri = test_data['uri']
redacted_str = UriCleaner.remove_sensitive(test_data['text'])
self.check_not_found(redacted_str, uri.username, uri.description)
self.check_not_found(redacted_str, uri.password, uri.description)
# Ensure the host didn't get redacted
self.check_found(redacted_str, uri.host, test_data['host_occurrences'], uri.description)
# should replace multiple secret data with safe string
def test_uri_scm_multiple_replaced():
cleartext = ''
find_count = 0
for uri in TEST_URIS:
cleartext += str(uri) + ' '
find_count += uri.get_secret_count()
for uri in TEST_URIS:
cleartext += str(uri) + '\n'
find_count += uri.get_secret_count()
redacted_str = UriCleaner.remove_sensitive(cleartext)
assert redacted_str.count(UriCleaner.REPLACE_STR) == find_count
# should redact and replace multiple secret data within a complex cleartext blob
def test_uri_scm_cleartext_redact_and_replace():
for test_data in TEST_CLEARTEXT:
uri = test_data['uri']
redacted_str = UriCleaner.remove_sensitive(test_data['text'])
assert uri.username not in redacted_str
assert uri.password not in redacted_str
# Ensure the host didn't get redacted
assert redacted_str.count(uri.host) == test_data['host_occurrences']

View File

@ -0,0 +1,48 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved
# Python
import mock
from mock import Mock
from StringIO import StringIO
from django.utils.timezone import now
# AWX
from awx.main.models import UnifiedJob
# stdout file present
@mock.patch('os.path.exists', return_value=True)
@mock.patch('codecs.open', return_value='my_file_handler')
def test_result_stdout_raw_handle_file__found(exists, open):
unified_job = UnifiedJob()
unified_job.result_stdout_file = 'dummy'
with mock.patch('os.stat', return_value=Mock(st_size=1)):
result = unified_job.result_stdout_raw_handle()
assert result == 'my_file_handler'
# stdout file missing, job finished
@mock.patch('os.path.exists', return_value=False)
def test_result_stdout_raw_handle__missing(exists):
unified_job = UnifiedJob()
unified_job.result_stdout_file = 'dummy'
unified_job.finished = now()
result = unified_job.result_stdout_raw_handle()
assert isinstance(result, StringIO)
assert result.read() == 'stdout capture is missing'
# stdout file missing, job not finished
@mock.patch('os.path.exists', return_value=False)
def test_result_stdout_raw_handle__pending(exists):
unified_job = UnifiedJob()
unified_job.result_stdout_file = 'dummy'
unified_job.finished = None
result = unified_job.result_stdout_raw_handle()
assert isinstance(result, StringIO)
assert result.read() == 'Waiting for results...'

View File

@ -25,6 +25,7 @@ DATABASES = {
'NAME': 'awx-dev',
'USER': 'awx-dev',
'PASSWORD': 'AWXsome1',
'ATOMIC_REQUESTS': True,
'HOST': 'postgres',
'PORT': '',
}

View File

@ -80,6 +80,11 @@ table, tbody {
border-top:0px!important;
}
.List-tableCell.description-column {
padding-top: 15px;
padding-bottom: 15px;
}
.List-actionButtonCell {
padding-top:5px;
padding-right: 15px;

View File

@ -1,7 +1,17 @@
@import "src/shared/text-label.less";
@import "src/shared/branding/colors.default.less";
.host-disabled-label {
&:after {
.include-text-label(#676767; white; "disabled");
display: inline-block;
content: "disabled";
border-radius: 3px;
color: @default-icon;
text-transform: uppercase;
font-size: .7em;
font-style: normal;
margin-left: 0.5em;
padding: 0.35em;
padding-bottom: 0.2em;
line-height: 1.1;
}
}

View File

@ -105,17 +105,17 @@
</div>
<div class="AddPermissions-footer">
<div class="buttons Form-buttons AddPermissions-buttons">
<button type="button"
class="btn btn-sm Form-cancelButton"
ng-click="closeModal()">
Cancel
</button>
<button type="button"
class="btn btn-sm Form-saveButton"
ng-click="updatePermissions()"
ng-disabled="userRoleForm.$invalid || !allSelected || !allSelected.length">
Save
</button>
<button type="button"
class="btn btn-sm Form-cancelButton"
ng-click="closeModal()">
Cancel
</button>
</div>
</div>
</div>

View File

@ -53,6 +53,10 @@
cursor: pointer;
}
.RoleList-tag--team {
cursor: default;
}
.RoleList-tagDelete {
font-size: 13px;
color: @default-bg;

View File

@ -7,8 +7,10 @@
class="fa fa-times RoleList-tagDelete"></i>
</div>
<div class="RoleList-tag"
ng-class="{'RoleList-tag--deletable': entry.explicit}">
ng-class="{'RoleList-tag--deletable': entry.explicit,
'RoleList-tag--team': entry.team_id}"
aw-tool-tip='{{entry.team_name | sanitize}}' aw-tip-placement='bottom'>
<span class="RoleList-name">{{ entry.name }}</span>
<i ng-show='entry.team_id' class="fa fa-users" ng-attr-title='{{entry.team_name}}'></i>
<i ng-show='entry.team_id' class="fa fa-users"></i>
</div>
</div>

View File

@ -255,6 +255,16 @@ var tower = angular.module('Tower', [
});
});
}]
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
}
}).
@ -264,6 +274,16 @@ var tower = angular.module('Tower', [
controller: JobsListController,
ncyBreadcrumb: {
label: "JOBS"
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
}
}).
@ -521,11 +541,12 @@ var tower = angular.module('Tower', [
'ClearScope', 'Socket', 'LoadConfig', 'Store',
'ShowSocketHelp', 'pendoService', 'Prompt', 'Rest', 'Wait',
'ProcessErrors', '$state', 'GetBasePath', 'ConfigService',
'FeaturesService',
'FeaturesService', '$filter',
function ($q, $compile, $cookieStore, $rootScope, $log, CheckLicense,
$location, Authorization, LoadBasePaths, Timer, ClearScope, Socket,
LoadConfig, Store, ShowSocketHelp, pendoService, Prompt, Rest, Wait,
ProcessErrors, $state, GetBasePath, ConfigService, FeaturesService) {
ProcessErrors, $state, GetBasePath, ConfigService, FeaturesService,
$filter) {
var sock;
$rootScope.addPermission = function (scope) {
$compile("<add-permissions class='AddPermissions'></add-permissions>")(scope);
@ -563,7 +584,7 @@ var tower = angular.module('Tower', [
if (accessListEntry.team_id) {
Prompt({
hdr: `Team access removal`,
body: `<div class="Prompt-bodyQuery">Please confirm that you would like to remove <span class="Prompt-emphasis">${entry.name}</span> access from the team <span class="Prompt-emphasis">${entry.team_name}</span>. This will affect all members of the team. If you would like to only remove access for this particular user, please remove them from the team.</div>`,
body: `<div class="Prompt-bodyQuery">Please confirm that you would like to remove <span class="Prompt-emphasis">${entry.name}</span> access from the team <span class="Prompt-emphasis">${$filter('sanitize')(entry.team_name)}</span>. This will affect all members of the team. If you would like to only remove access for this particular user, please remove them from the team.</div>`,
action: action,
actionText: 'REMOVE TEAM ACCESS'
});
@ -859,6 +880,7 @@ var tower = angular.module('Tower', [
$rootScope.$broadcast("EditIndicatorChange", list, id);
} else if ($rootScope.addedAnItem) {
delete $rootScope.addedAnItem;
$rootScope.$broadcast("RemoveIndicator");
} else {
$rootScope.$broadcast("RemoveIndicator");
}
@ -879,6 +901,7 @@ var tower = angular.module('Tower', [
}
// If browser refresh, set the user_is_superuser value
$rootScope.user_is_superuser = Authorization.getUserInfo('is_superuser');
$rootScope.user_is_system_auditor = Authorization.getUserInfo('is_system_auditor');
// state the user refreshes we want to open the socket, except if the user is on the login page, which should happen after the user logs in (see the AuthService module for that call to OpenSocket)
if(!_.contains($location.$$url, '/login')){
ConfigService.getConfig().then(function(){

View File

@ -6,7 +6,7 @@ export default
templateUrl: templateUrl('bread-crumb/bread-crumb'),
link: function(scope) {
var streamConfig = {};
var streamConfig = {}, originalRoute;
scope.showActivityStreamButton = false;
scope.showRefreshButton = false;
@ -30,16 +30,15 @@ export default
stateGoParams.id = $state.params[streamConfig.activityStreamId];
}
}
originalRoute = $state.current;
$state.go('activityStream', stateGoParams);
}
// The user is navigating away from the activity stream - take them back from whence they came
else {
// Pull the previous state out of local storage
var previousState = Store('previous_state');
if(previousState && !Empty(previousState.name)) {
$state.go(previousState.name, previousState.fromParams);
if(originalRoute) {
$state.go(originalRoute.name, originalRoute.fromParams);
}
else {
// If for some reason something went wrong (like local storage was wiped, etc) take the

View File

@ -391,6 +391,10 @@ export function ProjectsAdd(Refresh, $scope, $rootScope, $compile, $location, $l
defaultUrl = GetBasePath('projects'),
master = {};
// remove "type" field from search options
CredentialList = _.cloneDeep(CredentialList);
CredentialList.fields.kind.noSearch = true;
generator.inject(form, { mode: 'add', related: false, scope: $scope });
generator.reset();
@ -567,6 +571,11 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log,
id = $stateParams.id,
relatedSets = {};
// remove "type" field from search options
CredentialList = _.cloneDeep(CredentialList);
CredentialList.fields.kind.noSearch = true;
SchedulesList.well = false;
generator.inject(form, {
mode: 'edit',

View File

@ -22,7 +22,7 @@
$scope.formSave = function(){
var host = {
id: $scope.host.id,
variables: $scope.extraVars === '---' || $scope.extraVars === '{}' ? null : $scope.extraVars,
variables: $scope.variables === '---' || $scope.variables === '{}' ? null : $scope.variables,
name: $scope.name,
description: $scope.description,
enabled: $scope.host.enabled
@ -34,15 +34,14 @@
};
var init = function(){
$scope.host = host;
$scope.extraVars = host.variables === '' ? '---' : host.variables;
generator.inject(form, {mode: 'edit', related: false, scope: $scope});
$scope.extraVars = $scope.host.variables === '' ? '---' : $scope.host.variables;
$scope.name = host.name;
$scope.description = host.description;
$scope.variables = host.variables === '' ? '---' : host.variables;
ParseTypeChange({
scope: $scope,
field_id: 'host_variables',
variable: 'extraVars',
variable: 'variables',
});
};

View File

@ -6,8 +6,8 @@
export default
['$scope', '$state', '$stateParams', 'PageRangeSetup', 'GetBasePath', 'DashboardHostsList',
'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts',
function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts){
'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope',
function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope){
var setJobStatus = function(){
_.forEach($scope.hosts, function(value){
SetStatus({
@ -38,6 +38,20 @@ export default
});
setJobStatus();
});
var cleanUpStateChangeListener = $rootScope.$on('$stateChangeSuccess', function(event, toState, toParams) {
if (toState.name === "dashboardHosts.edit") {
$scope.rowBeingEdited = toParams.id;
$scope.listBeingEdited = "hosts";
}
else {
delete $scope.rowBeingEdited;
delete $scope.listBeingEdited;
}
});
// Remove the listener when the scope is destroyed to avoid a memory leak
$scope.$on('$destroy', function() {
cleanUpStateChangeListener();
});
var init = function(){
$scope.list = list;
$scope.host_active_search = false;
@ -59,6 +73,10 @@ export default
iterator: list.iterator
});
$scope.hostLoading = false;
if($state.current.name === "dashboardHosts.edit") {
$scope.rowBeingEdited = $state.params.id;
$scope.listBeingEdited = "hosts";
}
};
init();
}];

View File

@ -11,7 +11,7 @@
<table class="List-table">
<tr class="List-tableHeaderRow">
<th class="List-tableHeader DashboardList-tableHeader--name">
Title
Name
</th>
<th class="List-tableHeader DashboardList-tableHeader--activity">
Activity
@ -32,13 +32,15 @@
<td class="DashboardList-activityCell">
<aw-smart-status jobs="job_template.recent_jobs"></aw-smart-status>
</td>
<td class="List-actionButtonCell">
<button class="List-actionButton" ng-click="launchJobTemplate(job_template.id)">
<i class="icon-launch"></i>
</button>
<button class="List-actionButton" ng-click="editJobTemplate(job_template.id)">
<i class="fa fa-pencil"></i>
</button>
<td class="List-actionsContainer">
<div class="List-actionButtonCell">
<button class="List-actionButton" ng-click="launchJobTemplate(job_template.id)">
<i class="icon-launch"></i>
</button>
<button class="List-actionButton" ng-click="editJobTemplate(job_template.id)">
<i class="fa fa-pencil"></i>
</button>
</div>
</td>
</tr>
</table>

View File

@ -10,7 +10,7 @@
<div class="DashboardList-container">
<table class="List-table">
<tr>
<th class="List-tableHeader DashboardList-tableHeader--name">Title</th>
<th class="List-tableHeader DashboardList-tableHeader--name">Name</th>
<th class="List-tableHeader DashboardList-tableHeader--time">Time</th>
</tr>
<tr class="List-tableRow"

View File

@ -25,6 +25,7 @@ angular.module('JobTemplatesHelper', ['Utilities'])
return function(params) {
var scope = params.scope,
CredentialList = _.cloneDeep(CredentialList),
defaultUrl = GetBasePath('job_templates'),
// generator = GenerateForm,
form = JobTemplateForm(),
@ -162,6 +163,10 @@ angular.module('JobTemplatesHelper', ['Utilities'])
input_type: "radio"
});
CredentialList.basePath = GetBasePath('credentials') + '?kind=ssh';
// remove "type" field from search options
CredentialList.fields.kind.noSearch = true;
LookUpInit({
url: GetBasePath('credentials') + '?kind=ssh',
scope: scope,

View File

@ -231,6 +231,12 @@ export default
search_params = params.searchParams,
spinner = (params.spinner === undefined) ? true : params.spinner, key;
var buildTooltips = function(data){
data.forEach((val) => {
val.status_tip = 'Job ' + val.status + ". Click for details.";
});
};
GenerateList.inject(list, {
mode: 'edit',
id: id,
@ -260,6 +266,7 @@ export default
scope.$on('PostRefresh', function(){
JobsControllerInit({ scope: scope, parent_scope: parent_scope });
JobsListUpdate({ scope: scope, parent_scope: parent_scope, list: list });
buildTooltips(scope.jobs);
parent_scope.$emit('listLoaded');
});
@ -443,7 +450,7 @@ export default
return function(params) {
var scope = params.scope,
id = params.id;
InitiatePlaybookRun({ scope: scope, id: id });
InitiatePlaybookRun({ scope: scope, id: id, relaunch: true });
};
}])

View File

@ -71,7 +71,7 @@ export default
}
catch (e) {
Alert('Parse Error', 'Failed to parse valid YAML. ' + e.message);
setTimeout( function() { scope.$apply( function() { scope[model] = 'yaml'; createField(); }); }, 500);
setTimeout( function() { scope.$apply( function() { scope[model] = 'yaml'; createField(onReady, onChange, fld); }); }, 500);
}
}
else {
@ -89,7 +89,7 @@ export default
}
catch (e) {
Alert('Parse Error', 'Failed to parse valid JSON. ' + e.message);
setTimeout( function() { scope.$apply( function() { scope[model] = 'json'; createField(); }); }, 500 );
setTimeout( function() { scope.$apply( function() { scope[model] = 'json'; createField(onReady, onChange, fld); }); }, 500 );
}
}
};

View File

@ -70,8 +70,11 @@ export default
result = 'Success! Click for details';
break;
case 'failed':
case 'missing':
result = 'Failed. Click for details';
break;
case 'missing':
result = 'Missing. Click for details';
break;
}
return result;
};

View File

@ -197,7 +197,7 @@ function InventoriesList($scope, $rootScope, $location, $log,
". Click for details\" aw-tip-placement=\"top\"><i class=\"fa icon-job-" + row.status + "\"></i></a></td>\n";
html += "<td>" + ($filter('longDate')(row.finished)).replace(/ /,'<br />') + "</td>";
html += "<td><a href=\"#/jobs/" + row.id + "\" " + "aw-tool-tip=\"" + row.status.charAt(0).toUpperCase() + row.status.slice(1) +
". Click for details\" aw-tip-placement=\"top\">" + ellipsis(row.name) + "</a></td>";
". Click for details\" aw-tip-placement=\"top\">" + $filter('sanitize')(ellipsis(row.name)) + "</a></td>";
html += "</tr>\n";
});
html += "</tbody>\n";
@ -230,16 +230,16 @@ function InventoriesList($scope, $rootScope, $location, $log,
data.results.forEach( function(row) {
if (row.related.last_update) {
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><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="SmartStatus-tooltip--${row.status} 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 += "<td><a href=\"\" ng-click=\"viewJob('" + row.related.last_update + "')\">" + $filter('sanitize')(ellipsis(row.summary_fields.group.name)) + "</a></td>";
html += "</tr>\n";
}
else {
html += "<tr>";
html += "<td><a href=\"\" aw-tool-tip=\"No sync data\" aw-tip-placement=\"top\"><i class=\"fa icon-job-none\"></i></a></td>";
html += "<td>NA</td>";
html += "<td><a href=\"\">" + ellipsis(row.summary_fields.group.name) + "</a></td>";
html += "<td><a href=\"\">" + $filter('sanitize')(ellipsis(row.summary_fields.group.name)) + "</a></td>";
html += "</tr>\n";
}
});

View File

@ -44,7 +44,7 @@ export default function() {
autocomplete: false
},
limit: {
label: 'Host Pattern',
label: 'Limit',
type: 'text',
addRequired: false,
awPopOver: '<p>The pattern used to target hosts in the ' +
@ -54,7 +54,7 @@ export default function() {
'<a id=\"adhoc_form_hostpatterns_doc_link\"' +
'href=\"http://docs.ansible.com/intro_patterns.html\" ' +
'target=\"_blank\">here</a>.</p>',
dataTitle: 'Host Pattern',
dataTitle: 'Limit',
dataPlacement: 'right',
dataContainer: 'body'
},
@ -98,7 +98,7 @@ export default function() {
addRequired: true,
awPopOver:'<p>These are the verbosity levels for standard ' +
'out of the command run that are supported.',
dataTitle: 'Module',
dataTitle: 'Verbosity',
dataPlacement: 'right',
dataContainer: 'body',
"default": 1

View File

@ -12,6 +12,10 @@
var generator = GenerateForm,
form = GroupForm();
// remove "type" field from search options
CredentialList = _.cloneDeep(CredentialList);
CredentialList.fields.kind.noSearch = true;
$scope.formCancel = function(){
$state.go('^');
};
@ -118,7 +122,7 @@
$scope.group_by_choices = source === 'ec2' ? $scope.ec2_group_by : null;
// azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint
$scope.source_region_choices = source === 'azure_rm' ? $scope.azure_regions : $scope[source + '_regions'];
$scope.cloudCredentialRequired = source !== 'manual' && source !== 'custom' ? true : false;
$scope.cloudCredentialRequired = source !== '' && source !== 'custom' ? true : false;
$scope.group_by = null;
$scope.source_regions = null;
$scope.credential = null;

View File

@ -13,6 +13,11 @@
GroupManageService, GetChoices, GetBasePath, CreateSelect2, GetSourceTypeOptions, groupData, inventorySourceData){
var generator = GenerateForm,
form = GroupForm();
// remove "type" field from search options
CredentialList = _.cloneDeep(CredentialList);
CredentialList.fields.kind.noSearch = true;
$scope.formCancel = function(){
$state.go('^');
};
@ -117,7 +122,7 @@
// reset fields
// azure_rm regions choices are keyed as "azure" in an OPTIONS request to the inventory_sources endpoint
$scope.source_region_choices = source.value === 'azure_rm' ? $scope.azure_regions : $scope[source.value + '_regions'];
$scope.cloudCredentialRequired = source.value !== 'manual' && source.value !== 'custom' ? true : false;
$scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' ? true : false;
$scope.group_by = null;
$scope.source_regions = null;
$scope.credential = null;
@ -247,7 +252,7 @@
{inventory_script: inventorySourceData.source_script}
);
if (inventorySourceData.credential){
GroupManageService.getCredential(inventorySourceData.credential).then(res => $scope.credential_name = res.data.name);
$scope.credential_name = inventorySourceData.summary_fields.credential.name;
}
$scope = angular.extend($scope, groupData);

View File

@ -55,7 +55,7 @@ export default function(){
icon: 'fa-edit',
label: 'Edit',
"class": 'btn-sm',
awToolTip: 'Edit credential',
awToolTip: 'Edit inventory script',
dataPlacement: 'top'
},
"delete": {
@ -63,7 +63,7 @@ export default function(){
icon: 'fa-trash',
label: 'Delete',
"class": 'btn-sm',
awToolTip: 'Delete credential',
awToolTip: 'Delete inventory script',
dataPlacement: 'top'
}
}

View File

@ -18,93 +18,49 @@
$scope.search = function(){
Wait('start');
if ($scope.searchStr === undefined){
return;
}
//http://docs.ansible.com/ansible-tower/latest/html/towerapi/intro.html#filtering
// SELECT WHERE host_name LIKE str OR WHERE play LIKE str OR WHERE task LIKE str AND host_name NOT ""
// selecting non-empty host_name fields prevents us from displaying non-runner events, like playbook_on_task_start
JobDetailService.getRelatedJobEvents($stateParams.id, {
or__host_name__icontains: $scope.searchStr,
or__play__icontains: $scope.searchStr,
or__task__icontains: $scope.searchStr,
not__host_name: "" ,
page_size: $scope.pageSize})
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
var params = {
host_name: $scope.hostName,
};
if ($scope.searchStr && $scope.searchStr !== ''){
params.or__play__icontains = $scope.searchStr;
params.or__task__icontains = $scope.searchStr;
}
switch($scope.activeFilter){
case 'skipped':
params.event = 'runner_on_skipped';
break;
case 'unreachable':
params.event = 'runner_on_unreachable';
break;
case 'ok':
params.event = 'runner_on_ok';
break;
case 'failed':
params.failed = true;
break;
case 'changed':
params.changed = true;
break;
default:
break;
}
JobDetailService.getRelatedJobEvents($stateParams.id, params)
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
};
$scope.filters = ['all', 'changed', 'failed', 'ok', 'unreachable', 'skipped'];
var filter = function(filter){
Wait('start');
if (filter === 'all'){
return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName,
page_size: $scope.pageSize})
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
}
// handle runner cases
if (filter === 'skipped'){
return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName,
event: 'runner_on_skipped'})
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
}
if (filter === 'unreachable'){
return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName,
event: 'runner_on_unreachable'})
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
}
if (filter === 'ok'){
return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName,
event__startswith: 'runner_on_ok'
})
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
}
// handle convience properties .changed .failed
if (filter === 'changed'){
return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName,
changed: true})
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
}
if (filter === 'failed'){
return JobDetailService.getRelatedJobEvents($stateParams.id, {
host_name: $stateParams.hostName,
failed: true,
event__startswith: 'runner_on_failed'
})
.success(function(res){
$scope.results = res.results;
Wait('stop');
});
}
};
// watch select2 for changes
$('.HostEvents-select').on("select2:select", function () {
filter($('.HostEvents-select').val());
$scope.activeFilter = $('.HostEvents-select').val();
$scope.search();
});
var init = function(){
@ -116,12 +72,9 @@
});
// process the filter if one was passed
if ($stateParams.filter){
Wait('start');
filter($stateParams.filter).success(function(res){
$scope.results = res.results;
Wait('stop');
$('#HostEvents').modal('show');
});
$scope.activeFilter = $stateParams.filter;
$scope.search();
$('#HostEvents').modal('show');
}
else{
$scope.results = hosts.data.results;

View File

@ -1,7 +1,7 @@
<div id="hosts-summary-section" class="section">
<div class="JobDetail-instructions" ng-hide="hosts.length == 0 && !searchTerm"><span class="badge">4</span> Please select a host below to view a summary of all associated tasks.</div>
<div class="JobDetail-searchHeaderRow" ng-hide="hosts.length == 0 && !searchTerm">
<div class="JobDetail-instructions" ng-hide="hosts.length == 0 && !searchTerm && !searchActive"><span class="badge">4</span> Please select a host below to view a summary of all associated tasks.</div>
<div class="JobDetail-searchHeaderRow" ng-hide="hosts.length == 0 && !searchTerm && !searchActive">
<div class="JobDetail-searchContainer form-group">
<div class="search-name">
<form ng-submit="search()">
@ -22,7 +22,7 @@
</div>
</div>
<div class="table-header" ng-hide="hosts.length == 0 && !searchTerm">
<div class="table-header" ng-hide="hosts.length == 0 && !searchTerm && !searchActive">
<table class="table table-condensed">
<thead>
<tr>

View File

@ -17,5 +17,15 @@ export default {
},
ncyBreadcrumb: {
skip: true // Never display this state in breadcrumb.
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
}
};

View File

@ -13,13 +13,13 @@
export default
[ '$location', '$rootScope', '$filter', '$scope', '$compile', '$state', '$stateParams', '$log', 'ClearScope',
'GetBasePath', 'Wait', 'ProcessErrors', 'SelectPlay', 'SelectTask', 'GetElapsed', 'JobIsFinished',
'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'InitiatePlaybookRun', 'LoadPlays', 'LoadTasks',
'SetTaskStyles', 'DigestEvent', 'UpdateDOM', 'DeleteJob', 'RelaunchPlaybook', 'LoadPlays', 'LoadTasks',
'ParseVariableString', 'GetChoices', 'fieldChoices', 'fieldLabels', 'EditSchedule',
'ParseTypeChange', 'JobDetailService',
function(
$location, $rootScope, $filter, $scope, $compile, $state, $stateParams, $log, ClearScope,
GetBasePath, Wait, ProcessErrors, SelectPlay, SelectTask, GetElapsed, JobIsFinished,
SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, InitiatePlaybookRun, LoadPlays, LoadTasks,
SetTaskStyles, DigestEvent, UpdateDOM, DeleteJob, RelaunchPlaybook, LoadPlays, LoadTasks,
ParseVariableString, GetChoices, fieldChoices, fieldLabels, EditSchedule,
ParseTypeChange, JobDetailService
) {
@ -428,7 +428,7 @@ export default
var params = {
order_by: 'id'
};
if (scope.job.summary_fields.unified_job_template.unified_job_type === 'job'){
JobDetailService.getJobPlays(scope.job.id, params)
.success( function(data) {
scope.next_plays = data.next;
@ -514,7 +514,6 @@ export default
}
scope.$emit('LoadTasks', events_url);
});
}
});
@ -841,7 +840,7 @@ export default
};
scope.searchTasks = function() {
var params;
var params = {};
if (scope.search_task_name) {
scope.searchTasksEnabled = false;
}
@ -866,7 +865,7 @@ export default
};
scope.searchHosts = function() {
var params;
var params = {};
if (scope.search_host_name) {
scope.searchHostsEnabled = false;
}
@ -920,7 +919,7 @@ export default
};
scope.relaunchJob = function() {
InitiatePlaybookRun({
RelaunchPlaybook({
scope: scope,
id: scope.job.id
});

View File

@ -15,7 +15,7 @@
</a>
</div>
<div class="JobDetail-actions">
<button id="relaunch-job-button" class="List-actionButton JobDetail-launchButton" data-placement="top" mode="all" ng-click="relaunchJob()" aw-tool-tip="Relaunch using the same parameters" data-original-title="" title=""><i class="fa fa-rocket"></i> </button>
<button id="relaunch-job-button" class="List-actionButton JobDetail-launchButton" data-placement="top" mode="all" ng-click="relaunchJob()" aw-tool-tip="Relaunch using the same parameters" data-original-title="" title=""><i class="icon-launch"></i> </button>
<button id="cancel-job-button" class="List-actionButton List-actionButton--delete JobDetail-launchButton" data-placement="top" ng-click="deleteJob()" ng-show="job_status.status == 'running' || job_status.status=='pending' " aw-tool-tip="Cancel" data-original-title="" title=""><i class="fa fa-minus-circle"></i> </button>
<button id="delete-job-button" class="List-actionButton List-actionButton--delete JobDetail-launchButton" data-placement="top" ng-click="deleteJob()" ng-hide="job_status.status == 'running' || job_status.status == 'pending' " aw-tool-tip="Delete" data-original-title="" title=""><i class="fa fa-trash-o"></i> </button>
</div>
@ -164,7 +164,7 @@
<!--- end of results-->
<!--beginning of details-->
<div id="job-detail-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen && job.summary_fields.unified_job_template.unified_job_type == 'job'">
<div id="job-detail-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen">
<div class="JobDetail-panelHeader">
<div class="JobDetail-expandContainer">
<a class="JobDetail-panelHeaderText" ng-show="lessDetail" href="" ng-click="toggleLessDetail()">
@ -354,7 +354,7 @@
<tbody>
<tr class="List-tableRow cursor-pointer" ng-class-odd="'List-tableRow--oddRow'" ng-class-even="'List-tableRow--evenRow'" ng-repeat="result in results = (hostResults) track by $index">
<td class="List-tableCell col-lg-4 col-md-3 col-sm-3 col-xs-3 status-column">
<a ui-sref="jobDetail.host-event.details({eventId: result.id, taskId: selectedTask})" aw-tool-tip="Event ID: {{ result.id }}<br \>Status: {{ result.status_text }}. Click for details" data-placement="top"><i ng-show="result.status_text != 'Unreachable'" class="JobDetail-statusIcon fa icon-job-{{ result.status }}"></i><span ng-show="result.status_text != 'Unreachable'">{{ result.name }}</span><i ng-show="result.status_text == 'Unreachable'" class="JobDetail-statusIcon fa icon-job-unreachable"></i><span ng-show="result.status_text == 'Unreachable'">{{ result.name }}</span></a>
<a ui-sref="jobDetail.host-event.details({eventId: result.id, taskId: selectedTask})" aw-tool-tip="Event ID: {{ result.id }}<br \>Status: {{ result.status_text }}. Click for details" data-tip-watch="result.tip" data-placement="top"><i ng-show="result.status_text != 'Unreachable'" class="JobDetail-statusIcon fa icon-job-{{ result.status }}"></i><span ng-show="result.status_text != 'Unreachable'">{{ result.name }}</span><i ng-show="result.status_text == 'Unreachable'" class="JobDetail-statusIcon fa icon-job-unreachable"></i><span ng-show="result.status_text == 'Unreachable'">{{ result.name }}</span></a>
</td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3 item-column">{{ result.item }}</td>
<td class="List-tableCell col-lg-3 col-md-4 col-sm-3 col-xs-3">{{ result.msg }}</td>
@ -379,7 +379,7 @@
<!--end of details-->
<!--beginning of events summary-->
<div id="events-summary-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen && job.summary_fields.unified_job_template.unified_job_type == 'job'">
<div id="events-summary-panel" class="JobDetail-resultsContainer Panel" ng-show="!stdoutFullScreen">
<div class="JobDetail-panelHeader">
<div class="JobDetail-expandContainer">
<a class="JobDetail-panelHeaderText" ng-show="lessEvents" ui-sref="jobDetail.host-summary" ng-click="toggleLessEvents()">

View File

@ -30,5 +30,15 @@ export default {
}]
},
templateUrl: templateUrl('job-detail/job-detail'),
controller: 'JobDetailController'
controller: 'JobDetailController',
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
}
};

View File

@ -39,6 +39,13 @@ export default
}
catch(err){return;}
},
processsEventTip: function(event, status){
try{
var string = `Event ID: ${ event.id }<br>Status: ${ _.capitalize(status.status)}. Click for details`;
return typeof item === 'object' ? JSON.stringify(string) : string;
}
catch(err){return;}
},
// Generate a helper class for job_event statuses
// the stack for which status to display is
// unreachable > failed > changed > ok
@ -88,6 +95,7 @@ export default
var status = self.processEventStatus(event);
var msg = self.processEventMsg(event);
var item = self.processEventItem(event);
var tip = self.processsEventTip(event, status);
results.push({
id: event.id,
status: status.status,
@ -96,6 +104,7 @@ export default
task_id: event.parent,
name: event.event_data.host,
created: event.created,
tip: typeof tip === 'undefined' ? undefined : tip,
msg: typeof msg === 'undefined' ? undefined : msg,
item: typeof item === 'undefined' ? undefined : item
});

View File

@ -9,10 +9,11 @@ export default
return function (params) {
var scope = params.scope.$new(),
id = params.id,
relaunch = params.relaunch || false,
system_job = params.system_job || false;
scope.job_template_id = id;
var el = $compile( "<submit-job data-submit-job-id=" + id + " data-submit-job-system=" + system_job + "></submit-job>" )( scope );
var el = $compile( "<submit-job data-submit-job-id=" + id + " data-submit-job-system=" + system_job + " data-submit-job-relaunch=" + relaunch + "></submit-job>" )( scope );
$('#content-container').remove('submit-job').append( el );
};
}

View File

@ -103,8 +103,8 @@
}
.JobSubmission-step--active {
color: @default-bg!important;
background-color: @d7grey!important;
border-color: @d7grey!important;
background-color: @default-icon!important;
border-color: @default-icon!important;
cursor: default!important;
}
.JobSubmission-step--disabled {

View File

@ -128,14 +128,14 @@ export default
// This gets things started - goes out and hits the launch endpoint (based on launch/relaunch) and
// prepares the form fields, defauts, etc.
$scope.init = function() {
$scope.forms = {};
$scope.passwords = {};
var base = $location.path().replace(/^\//, '').split('/')[0],
isRelaunch = !(base === 'job_templates' || base === 'portal' || base === 'inventories' || base === 'home');
// As of 3.0, the only place the user can relaunch a
// playbook is on jobTemplates.edit (completed_jobs tab),
// jobs, and jobDetails $states.
if (!isRelaunch) {
if (!$scope.submitJobRelaunch) {
launch_url = GetBasePath('job_templates') + $scope.submitJobId + '/launch/';
}
else {
@ -187,7 +187,7 @@ export default
updateRequiredPasswords();
}
if( (isRelaunch && !$scope.password_needed) || (!isRelaunch && $scope.can_start_without_user_input && !$scope.ask_inventory_on_launch && !$scope.ask_credential_on_launch && !$scope.has_other_prompts && !$scope.survey_enabled)) {
if( ($scope.submitJobRelaunch && !$scope.password_needed) || (!$scope.submitJobRelaunch && $scope.can_start_without_user_input && !$scope.ask_inventory_on_launch && !$scope.ask_credential_on_launch && !$scope.has_other_prompts && !$scope.survey_enabled)) {
// The job can be launched if
// a) It's a relaunch and no passwords are needed
// or
@ -215,7 +215,7 @@ export default
$scope.openLaunchModal();
};
if(isRelaunch) {
if($scope.submitJobRelaunch) {
// Go out and get some of the job details like inv, cred, name
Rest.setUrl(GetBasePath('jobs') + $scope.submitJobId);
Rest.get()
@ -331,6 +331,7 @@ export default
var credential_url = GetBasePath('credentials') + '?kind=ssh';
var credList = _.cloneDeep(CredentialList);
credList.basePath = GetBasePath('credentials') + '?kind=ssh';
credList.fields.description.searchable = false;
credList.fields.kind.searchable = false;
@ -536,8 +537,9 @@ export default
// It shares the same scope with this directive and will
// pull the new value of parseType out to determine which
// direction to convert the extra vars
$scope.parseType = $scope.other_prompt_data.parseType;
$scope.parseTypeChange();
$scope.parseTypeChange('parseType', 'jobLaunchVariables');
};
}

View File

@ -11,7 +11,8 @@ export default [ 'templateUrl', 'CreateDialog', 'Wait', 'CreateSelect2', 'ParseT
return {
scope: {
submitJobId: '=',
submitJobSystem: '='
submitJobSystem: '=',
submitJobRelaunch: '='
},
templateUrl: templateUrl('job-submission/job-submission'),
controller: jobSubmissionController,

View File

@ -34,6 +34,9 @@
base = $location.path().replace(/^\//, '').split('/')[0],
context = (base === 'job_templates') ? 'job_template' : 'inv';
// remove "type" field from search options
CredentialList.fields.kind.noSearch = true;
CallbackHelpInit({ scope: $scope });
$scope.can_edit = true;
generator.inject(form, { mode: 'add', related: false, scope: $scope });
@ -82,6 +85,7 @@
NetworkCredentialList.iterator = 'networkcredential';
NetworkCredentialList.basePath = '/api/v1/credentials?kind=net';
SurveyControllerInit({
scope: $scope,
parent_scope: $scope
@ -375,7 +379,7 @@
</string>
</p>
</div>`,
'alert-info', saveCompleted, null, null,
'alert-danger', saveCompleted, null, null,
null, true);
}
var orgDefer = $q.defer();

View File

@ -14,5 +14,19 @@ export default {
ncyBreadcrumb: {
parent: "jobTemplates",
label: "CREATE JOB TEMPLATE"
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
if($("#survey-modal-dialog").hasClass('ui-dialog-content')) {
$('#survey-modal-dialog').dialog('destroy');
}
}
};

View File

@ -46,6 +46,9 @@ export default
checkSCMStatus, getPlaybooks, callback,
choicesCount = 0;
// remove "type" field from search options
CredentialList = _.cloneDeep(CredentialList);
CredentialList.fields.kind.noSearch = true;
CallbackHelpInit({ scope: $scope });
@ -347,20 +350,13 @@ export default
ParseTypeChange({ scope: $scope, field_id: 'job_templates_variables', onChange: callback });
if (related_cloud_credential) {
Rest.setUrl(related_cloud_credential);
Rest.get()
.success(function (data) {
$scope.$emit('cloudCredentialReady', data.name);
})
.error(function (data, status) {
ProcessErrors($scope, data, status, null, {hdr: 'Error!',
msg: 'Failed to related cloud credential. GET returned status: ' + status });
});
if($scope.job_template_obj.summary_fields.cloud_credential && related_cloud_credential) {
$scope.$emit('cloudCredentialReady', $scope.job_template_obj.summary_fields.cloud_credential.name);
} else {
// No existing cloud credential
$scope.$emit('cloudCredentialReady', null);
}
});
Wait('start');
@ -446,31 +442,40 @@ export default
}
});
};
if($state.params.id !== "null"){
Rest.setUrl(defaultUrl + $state.params.id +
"/labels");
Rest.get()
.success(function(data) {
if (data.next) {
getNext(data, data.results, seeMoreResolve);
} else {
seeMoreResolve.resolve(data.results);
}
Rest.setUrl(defaultUrl + $state.params.id +
"/labels");
Rest.get()
.success(function(data) {
if (data.next) {
getNext(data, data.results, seeMoreResolve);
} else {
seeMoreResolve.resolve(data.results);
}
seeMoreResolve.promise.then(function (labels) {
$scope.$emit("choicesReady");
var opts = labels
.map(i => ({id: i.id + "",
test: i.name}));
CreateSelect2({
element:'#job_templates_labels',
multiple: true,
addNew: true,
opts: opts
seeMoreResolve.promise.then(function (labels) {
$scope.$emit("choicesReady");
var opts = labels
.map(i => ({id: i.id + "",
test: i.name}));
CreateSelect2({
element:'#job_templates_labels',
multiple: true,
addNew: true,
opts: opts
});
Wait("stop");
});
Wait("stop");
}).error(function(){
// job template id is null in this case
$scope.$emit("choicesReady");
});
});
}
else {
// job template doesn't exist
$scope.$emit("choicesReady");
}
})
.error(function (data, status) {
ProcessErrors($scope, data, status, form, {

View File

@ -17,5 +17,19 @@ export default {
ncyBreadcrumb: {
parent: 'jobTemplates',
label: "{{name}}"
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
if($("#survey-modal-dialog").hasClass('ui-dialog-content')) {
$('#survey-modal-dialog').dialog('destroy');
}
}
};

View File

@ -74,13 +74,17 @@ export default
});
};
scope.$watch(scope.$parent.list.iterator+".summary_fields.labels.results", function() {
scope.$watchCollection(scope.$parent.list.iterator, function() {
// To keep the array of labels fresh, we need to set up a watcher - otherwise, the
// array will get set initially and then never be updated as labels are removed
if (scope[scope.$parent.list.iterator].summary_fields.labels){
scope.labels = scope[scope.$parent.list.iterator].summary_fields.labels.results;
scope.count = scope[scope.$parent.list.iterator].summary_fields.labels.count;
}
else{
scope.labels = null;
scope.count = null;
}
});
}
};

View File

@ -17,5 +17,15 @@ export default {
},
ncyBreadcrumb: {
label: "JOB TEMPLATES"
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
}
};

View File

@ -21,7 +21,8 @@ export default
label: '',
searchLabel: 'Status',
columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus',
awToolTip: "Job {{ all_job.status }}. Click for details.",
dataTipWatch: 'all_job.status_tip',
awToolTip: "{{ all_job.status_tip }}",
awTipPlacement: "right",
dataTitle: "{{ all_job.status_popover_title }}",
icon: 'icon-job-{{ all_job.status }}',
@ -48,6 +49,7 @@ export default
ngClick: "viewJobDetails(all_job)",
defaultSearchField: true,
awToolTip: "{{ all_job.name | sanitize }}",
dataTipWatch: 'all_job.name',
dataPlacement: 'top'
},
type: {

View File

@ -45,6 +45,7 @@ export default
owners: {
label: 'Owners',
type: 'owners',
searchable: false,
nosort: true,
excludeModal: true,
columnClass: 'col-md-2 hidden-sm hidden-xs'

View File

@ -54,6 +54,10 @@ export default
label: 'Disabled?',
searchSingleValue: true,
searchType: 'boolean',
typeOptions: [
{label: "Yes", value: false},
{label: "No", value: true}
],
searchValue: 'false',
searchOnly: true
},

View File

@ -43,6 +43,7 @@ export default
searchable: false,
filter: "longDate",
key: true,
desc: true,
columnClass: "col-lg-4 col-md-4 col-sm-3"
}
},

View File

@ -35,6 +35,7 @@ export default
sourceField: 'name',
ngClick: "editSchedule(schedule)",
awToolTip: "{{ schedule.nameTip | sanitize}}",
dataTipWatch: 'schedule.nameTip',
dataPlacement: "top",
defaultSearchField: true
},
@ -45,14 +46,17 @@ export default
sourceModel: 'unified_job_template',
sourceField: 'unified_job_type',
ngBind: 'schedule.type_label',
searchField: 'unified_job_template__polymorphic_ctype__name',
searchLable: 'Type',
searchField: 'unified_job_template__polymorphic_ctype__model',
filterBySearchField: true,
searchLabel: 'Type',
searchable: true,
searchType: 'select',
searchOptions: [
{ value: 'inventory source', label: 'Inventory Sync' },
{ value: 'job template', label: 'Playbook Run' },
{ value: 'project', label: 'SCM Update' }
{ value: 'inventorysource', label: 'Inventory Sync' },
{ value: 'jobtemplate', label: 'Playbook Run' },
{ value: 'project', label: 'SCM Update' },
{ value: 'systemjobtemplate', label: 'Management Job'}
]
},
next_run: {

View File

@ -137,6 +137,7 @@ export default ['$log', '$cookieStore', '$compile', '$window', '$rootScope',
$rootScope.sessionTimer = timer;
$rootScope.$emit('OpenSocket');
$rootScope.user_is_superuser = data.results[0].is_superuser;
$rootScope.user_is_system_auditor = data.results[0].is_system_auditor;
scope.$emit('AuthorizationGetLicense');
});
})

View File

@ -18,7 +18,7 @@
<button class="MgmtCards-actionItem List-actionButton"
ng-click='chooseRunJob(card.id, card.name)'
data-placement="top" aw-tool-tip="Launch Management Job" data-original-title="" title="">
<i class="MgmtCards-actionItemIcon fa fa-rocket"></i>
<i class="MgmtCards-actionItemIcon icon-launch"></i>
</button>
<button class="MgmtCards-actionItem List-actionButton"
ng-click='configureSchedule(card.id)'

View File

@ -131,7 +131,7 @@ export default
recent_notifications.forEach(function(row) {
html += "<tr>\n";
html += "<td><i class=\"fa icon-job-" + row.status + "\"></i></td>\n";
html += `<td><i class=\"SmartStatus-tooltip--${row.status} fa icon-job-${row.status}"></i></td>`;
html += "<td>" + ($filter('longDate')(row.created)).replace(/ /,'<br />') + "</td>\n";
html += "</tr>\n";
});
@ -147,7 +147,9 @@ export default
};
scope.testNotification = function(){
var name = $filter('sanitize')(this.notification_template.name);
var name = $filter('sanitize')(this.notification_template.name),
pending_retries = 10;
Rest.setUrl(defaultUrl + this.notification_template.id +'/test/');
Rest.post({})
.then(function (data) {
@ -156,31 +158,7 @@ export default
// Using a setTimeout here to wait for the
// notification to be processed and for a status
// to be returned from the API.
setTimeout(function(){
var id = data.data.notification,
url = GetBasePath('notifications') + id;
Rest.setUrl(url);
Rest.get()
.then(function (res) {
Wait('stop');
if(res && res.data && res.data.status && res.data.status === "successful"){
scope.search(list.iterator);
ngToast.success({
content: `<i class="fa fa-check-circle Toast-successIcon"></i> <b>${name}:</b> Notification sent.`
});
}
else if(res && res.data && res.data.status && res.data.status === "failed"){
scope.search(list.iterator);
ngToast.danger({
content: `<i class="fa fa-exclamation-triangle Toast-successIcon"></i> <b>${name}:</b> Notification failed.`
});
}
else {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. Notification returned status: ' + status });
}
});
} , 5000);
retrieveStatus(data.data.notification);
}
else {
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
@ -192,6 +170,40 @@ export default
content: `<i class="fa fa-check-circle Toast-successIcon"></i> <b>${name}:</b> Notification Failed.`,
});
});
function retrieveStatus(id){
setTimeout(function(){
var url = GetBasePath('notifications') + id;
Rest.setUrl(url);
Rest.get()
.then(function (res) {
if(res && res.data && res.data.status && res.data.status === "successful"){
scope.search(list.iterator);
ngToast.success({
content: `<i class="fa fa-check-circle Toast-successIcon"></i> <b>${name}:</b> Notification sent.`
});
Wait('stop');
}
else if(res && res.data && res.data.status && res.data.status === "failed"){
scope.search(list.iterator);
ngToast.danger({
content: `<i class="fa fa-exclamation-triangle Toast-successIcon"></i> <b>${name}:</b> Notification failed.`
});
Wait('stop');
}
else if(res && res.data && res.data.status && res.data.status === "pending" && pending_retries>0){
pending_retries--;
retrieveStatus(id);
}
else {
Wait('stop');
ProcessErrors(scope, null, status, null, { hdr: 'Error!',
msg: 'Call to test notifications failed.' });
}
});
} , 5000);
}
};
scope.addNotification = function(){

View File

@ -121,6 +121,16 @@ export default [
features: ['FeaturesService', function(FeaturesService) {
return FeaturesService.get();
}]
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
}
},
{

View File

@ -7,7 +7,7 @@
export function PortalModeJobsController($scope, $rootScope, GetBasePath, GenerateList, PortalJobsList, SearchInit,
PaginateInit){
var list = PortalJobsList,
var list = _.cloneDeep(PortalJobsList),
view = GenerateList,
// show user jobs by default
defaultUrl = GetBasePath('jobs') + '?created_by=' + $rootScope.current_user.id,
@ -23,7 +23,13 @@ export function PortalModeJobsController($scope, $rootScope, GetBasePath, Genera
$scope.iterator = list.iterator;
$scope.activeFilter = 'user';
var init = function(sort){
var init = function(url){
defaultUrl = url ? url : defaultUrl;
// We need to explicitly set the lists base path so that tag searching will keep the '?created_by'
// query param when it's present. If we don't do this, then tag search will just grab the base
// path for this list (/api/v1/jobs) and lose the created_by filter
list.basePath = defaultUrl;
view.inject(list, {
id: 'portal-jobs',
mode: 'edit',
@ -44,32 +50,26 @@ export function PortalModeJobsController($scope, $rootScope, GetBasePath, Genera
url: defaultUrl,
pageSize: pageSize
});
$scope.search (list.iterator);
if(sort) {
// hack to default to descending sort order
$scope.sort('job','finished');
}
$scope.search(list.iterator);
};
$scope.filterUser = function(){
$scope.activeFilter = 'user';
defaultUrl = GetBasePath('jobs') + '?created_by=' + $rootScope.current_user.id;
init();
init(defaultUrl);
};
$scope.filterAll = function(){
$scope.activeFilter = 'all';
defaultUrl = GetBasePath('jobs');
init();
init(defaultUrl);
};
$scope.refresh = function(){
$scope.search(list.iterator);
};
init(true);
init();
}
PortalModeJobsController.$inject = ['$scope', '$rootScope', 'GetBasePath', 'generateList', 'PortalJobsList', 'SearchInit',

View File

@ -24,5 +24,15 @@ export default {
templateUrl: templateUrl('portal-mode/portal-mode-jobs'),
controller: PortalModeJobsController
}
},
onExit: function(){
// close the job launch modal
// using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X"
// Destroy the dialog
if($("#job-launch-modal").hasClass('ui-dialog-content')) {
$('#job-launch-modal').dialog('destroy');
}
// Remove the directive from the page (if it's there)
$('#content-container').find('submit-job').remove();
}
};

View File

@ -78,11 +78,11 @@ export default ['$compile', '$filter', '$state', '$stateParams', 'AddSchedule',
// extra_data field is not manifested in the UI when scheduling a Management Job
if ($state.current.name === 'jobTemplateSchedules.add'){
$scope.parseType = 'yaml';
// grab any existing extra_vars from parent job_template
var defaultUrl = GetBasePath('job_templates') + $stateParams.id + '/';
Rest.setUrl(defaultUrl);
Rest.get().then(function(res){
// sanitize
var data = JSON.parse(JSON.stringify(res.data.extra_vars));
var data = res.data.extra_vars;
$scope.extraVars = data === '' ? '---' : data;
ParseTypeChange({
scope: $scope,

View File

@ -36,6 +36,7 @@ export default
mustUpdateValue = false;
scope.dateValueMoment = moment(newValue, ['MM/DD/YYYY'], moment.locale());
scope.dateValue = scope.dateValueMoment.format('L');
element.find(".DatePicker").systemTrackingDP('update', scope.dateValue);
}
}, true);
@ -54,7 +55,7 @@ export default
element.find(".DatePicker").addClass("input-prepend date");
element.find(".DatePicker").find(".DatePicker-icon").addClass("add-on");
$(".date").systemTrackingDP({
element.find(".DatePicker").systemTrackingDP({
autoclose: true,
language: localeKey,
format: dateFormat

View File

@ -1,4 +1,20 @@
export default ['$compile', '$state', '$stateParams', 'EditSchedule', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange', function($compile, $state, $stateParams, EditSchedule, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange) {
export default ['$filter', '$compile', '$state', '$stateParams', 'EditSchedule', 'Wait', '$scope', '$rootScope', 'CreateSelect2', 'ParseTypeChange',
function($filter, $compile, $state, $stateParams, EditSchedule, Wait, $scope, $rootScope, CreateSelect2, ParseTypeChange) {
$scope.processSchedulerEndDt = function(){
// set the schedulerEndDt to be equal to schedulerStartDt + 1 day @ midnight
var dt = new Date($scope.schedulerUTCTime);
// increment date by 1 day
dt.setDate(dt.getDate() + 1);
var month = $filter('schZeroPad')(dt.getMonth() + 1, 2),
day = $filter('schZeroPad')(dt.getDate(), 2);
$scope.$parent.schedulerEndDt = month + '/' + day + '/' + dt.getFullYear();
};
// initial end @ midnight values
$scope.schedulerEndHour = "00";
$scope.schedulerEndMinute = "00";
$scope.schedulerEndSecond = "00";
$scope.$on("ScheduleFormCreated", function(e, scope) {
$scope.hideForm = false;
$scope = angular.extend($scope, scope);

View File

@ -630,8 +630,8 @@
</a>
<div class="parse-selection">
<input type="radio" ng-model="parseType" ng-change="parseTypeChange()" value="yaml"><span class="parse-label">YAML</span>
<input type="radio" ng-model="parseType" ng-change="parseTypeChange()" value="json"> <span class="parse-label">JSON</span>
<input type="radio" ng-model="parseType" ng-change="parseTypeChange('parseType', 'extraVars')" value="yaml"><span class="parse-label">YAML</span>
<input type="radio" ng-model="parseType" ng-change="parseTypeChange('parseType', 'extraVars')" value="json"> <span class="parse-label">JSON</span>
</div>
</label>

View File

@ -1,17 +1,19 @@
export default ['$scope', 'Refresh', 'tagSearchService',
function($scope, Refresh, tagSearchService) {
export default ['$scope', 'Refresh', 'tagSearchService', '$stateParams',
function($scope, Refresh, tagSearchService, $stateParams) {
// JSONify passed field elements that can be searched
$scope.list = angular.fromJson($scope.list);
// Access config lines from list spec
$scope.listConfig = $scope.$parent.list;
// Grab options for the left-dropdown of the searchbar
tagSearchService.getSearchTypes($scope.list, $scope.endpoint)
.then(function(searchTypes) {
$scope.searchTypes = searchTypes;
if($stateParams.id !== "null"){
tagSearchService.getSearchTypes($scope.list, $scope.endpoint)
.then(function(searchTypes) {
$scope.searchTypes = searchTypes;
// currently selected option of the left-dropdown
$scope.currentSearchType = $scope.searchTypes[0];
});
// currently selected option of the left-dropdown
$scope.currentSearchType = $scope.searchTypes[0];
});
}
// shows/hide the search type dropdown
$scope.toggleTypeDropdown = function() {

View File

@ -6,7 +6,10 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu
var obj = {};
// build the value (key)
var value;
if (field.sourceModel && field.sourceField) {
if (field.searchField && field.filterBySearchField === true){
value = field.searchField;
}
else if (field.sourceModel && field.sourceField) {
value = field.sourceModel + '__' + field.sourceField;
obj.related = true;
} else if (typeof(field.key) === String) {
@ -25,7 +28,7 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu
typeOptions = field.searchOptions || [];
} else if (field.searchType === 'boolean') {
type = 'select';
typeOptions = [{label: "Yes", value: true},
typeOptions = field.typeOptions || [{label: "Yes", value: true},
{label: "No", value: false}];
} else {
type = 'text';
@ -81,41 +84,45 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu
if (needsRequest.length) {
// make the options request to reutrn the typeOptions
Rest.setUrl(needsRequest[0].basePath ? GetBasePath(needsRequest[0].basePath) : basePath);
Rest.options()
.success(function (data) {
try {
var options = data.actions.GET;
needsRequest = needsRequest
.map(function (option) {
option.typeOptions = options[option
.value]
.choices
.map(function(i) {
return {
value: i[0],
label: i[1]
};
});
return option;
});
}
catch(err){
if (!basePath){
$log.error('Cannot retrieve OPTIONS because the basePath parameter is not set on the list with the following fieldset: \n', list);
var url = needsRequest[0].basePath ? GetBasePath(needsRequest[0].basePath) : basePath;
if(url.indexOf('null') === 0 ){
Rest.setUrl(url);
Rest.options()
.success(function (data) {
try {
var options = data.actions.GET;
needsRequest = needsRequest
.map(function (option) {
option.typeOptions = options[option
.value]
.choices
.map(function(i) {
return {
value: i[0],
label: i[1]
};
});
return option;
});
}
else { $log.error(err); }
}
Wait("stop");
defer.resolve(joinOptions());
})
.error(function (data, status) {
Wait("stop");
defer.reject("options request failed");
ProcessErrors(null, data, status, null, {
hdr: 'Error!',
msg: 'Getting type options failed'});
});
catch(err){
if (!basePath){
$log.error('Cannot retrieve OPTIONS because the basePath parameter is not set on the list with the following fieldset: \n', list);
}
else { $log.error(err); }
}
Wait("stop");
defer.resolve(joinOptions());
})
.error(function (data, status) {
Wait("stop");
defer.reject("options request failed");
ProcessErrors(null, data, status, null, {
hdr: 'Error!',
msg: 'Getting type options failed'});
});
}
} else {
Wait("stop");
defer.resolve(joinOptions());
@ -179,7 +186,7 @@ export default ['Rest', '$q', 'GetBasePath', 'Wait', 'ProcessErrors', '$log', fu
this.getTag = function(field, textVal, selectVal) {
var tag = _.clone(field);
if (tag.type === "text") {
tag.url = tag.value + "__icontains=" + textVal;
tag.url = tag.value + "__icontains=" + encodeURIComponent(textVal);
tag.name = textVal;
} else if (selectVal.value && typeof selectVal.value === 'string' && selectVal.value.indexOf("=") > 0) {
tag.url = selectVal.value;

View File

@ -24,7 +24,7 @@
Add passwords, SSH keys, etc. for Tower to use when launching jobs against machines, or when syncing inventories or projects.
</p>
</a>
<a ui-sref="managementJobsList" class="SetupItem" ng-if="user_is_superuser">
<a ui-sref="managementJobsList" class="SetupItem" ng-if="user_is_superuser || user_is_system_auditor">
<h4 class="SetupItem-title">Management Jobs</h4>
<p class="SetupItem-description">
Manage the cleanup of old job history, activity streams, data marked for deletion, and system tracking info.
@ -37,7 +37,7 @@
</p>
</a>
<a ui-sref="notifications" class="SetupItem"
ng-if="orgAdmin">
ng-if="orgAdmin || user_is_system_auditor || user_is_superuser">
<h4 class="SetupItem-title">Notifications</h4>
<p class="SetupItem-description">
Create templates for sending notifications with Email, HipChat, Slack, and SMS.

View File

@ -1960,7 +1960,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
// Row level actions
if (collection.fieldActions) {
html += "<td class=\"List-tableCell List-actionButtonCell actions\">";
html += "<td class=\"List-actionsContainer\"><div class=\"List-tableCell List-actionButtonCell actions\">";
for (act in collection.fieldActions) {
fAction = collection.fieldActions[act];
html += "<button id=\"" + ((fAction.id) ? fAction.id : act + "-action") + "\" ";
@ -1986,7 +1986,7 @@ angular.module('FormGenerator', [GeneratorHelpers.name, 'Utilities', listGenerat
//html += (fAction.label) ? "<span class=\"list-action-label\"> " + fAction.label + "</span>": "";
html += "</button>";
}
html += "</td>";
html += "</div></td>";
html += "</tr>\n";
}

View File

@ -137,10 +137,10 @@ angular.module('GeneratorHelpers', [systemStatus.name])
case 'run':
case 'rerun':
case 'submit':
icon = 'fa-rocket';
icon = 'icon-launch';
break;
case 'launch':
icon = 'fa-rocket';
icon = 'icon-launch';
break;
case 'stream':
icon = 'fa-clock-o';

View File

@ -524,7 +524,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate
// Row level actions
innerTable += "<td class=\"List-actionButtonCell List-tableCell\">";
innerTable += "<td class=\"List-actionsContainer\"><div class=\"List-actionButtonCell List-tableCell\">";
for (field_action in list.fieldActions) {
if (field_action !== 'columnClass') {
@ -573,7 +573,7 @@ export default ['$location', '$compile', '$rootScope', 'SearchWidget', 'Paginate
}
}
}
innerTable += "</td>\n";
innerTable += "</div></td>\n";
}
innerTable += "</tr>\n";

View File

@ -38,6 +38,7 @@
line-height: 22px;
}
.SmartStatus-tooltip--successful,
.SmartStatus-tooltip--success{
color: @default-succ;
padding-left: 5px;

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