Merge branch 'release_3.0.2' into stable

* release_3.0.2: (150 commits)
  Updating changelogs for 3.0.2 release
  fixing deprecated_team.organization credential migration
  Fix issue when installing bundled ansible on el6
  fixed localed date stuff
  update test to check org_auditor access
  ensure team organizations are assigned to credentials
  temporarily pin the pytest version until the ldap error can be fixed
  fixed locale
  fix date locale angular scheduler
  Make sure org admins can see credential after migration, comment updates on related tests add clause in test to verify automatic setting of org of new team credential
  Update team admin credential migration test to current state-of-knowledge
  fix ng-toast rel, resolves #3197 (#3316)
  allow users to edit their first and last name
  Revert "Prevent ignored task from being displayed as failing."
  Revert "Modify job event save behavior"
  fixing old tests for new user creation permissions
  Clean venv on 'make clean'
  Resolve KeyError by coercing instance_id to a str
  Update cloudforms dynamic inventory
  Update foreman inventory script
  ...
This commit is contained in:
Matthew Jones
2016-08-31 14:30:58 -04:00
124 changed files with 2405 additions and 1430 deletions

View File

@@ -172,7 +172,7 @@ endif
.DEFAULT_GOAL := build
.PHONY: clean clean-tmp rebase push requirements requirements_dev \
.PHONY: clean clean-tmp clean-venv rebase push requirements requirements_dev \
requirements_jenkins \
develop refresh adduser migrate dbchange dbshell runserver celeryd \
receiver test test_unit test_coverage coverage_html test_jenkins dev_build \
@@ -182,8 +182,8 @@ endif
websocket-proxy browser-sync browser-sync-reload brocolli-watcher \
devjs minjs testjs_ci \
deb deb-src debian debsign pbuilder reprepro setup_tarball \
virtualbox-ovf virtualbox-centos-7 virtualbox-centos-6 \
clean-bundle setup_bundle_tarball
vagrant-virtualbox virtualbox-centos-7 virtualbox-centos-6 \
vagrant-vmware clean-bundle setup_bundle_tarball
# Remove setup build files
clean-tar:
@@ -229,8 +229,11 @@ clean-build-test:
clean-tmp:
rm -rf tmp/
clean-venv:
rm -rf venv/
# Remove temporary build files, compiled Python files.
clean: clean-rpm clean-deb clean-grunt clean-ui clean-static clean-build-test clean-tar clean-packer clean-bundle
clean: clean-rpm clean-deb clean-grunt clean-ui clean-static clean-build-test clean-tar clean-packer clean-bundle clean-venv
rm -rf awx/lib/site-packages
rm -rf awx/lib/.deps_built
rm -rf dist/*
@@ -448,13 +451,22 @@ check: flake8 pep8 # pyflakes pylint
TEST_DIRS=awx/main/tests
# Run all API unit tests.
test:
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \
fi; \
py.test $(TEST_DIRS)
test_unit:
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \
fi; \
py.test awx/main/tests/unit
# Run all API unit tests with coverage enabled.
test_coverage:
@if [ "$(VENV_BASE)" ]; then \
. $(VENV_BASE)/tower/bin/activate; \
fi; \
py.test --create-db --cov=awx --cov-report=xml --junitxml=./reports/junit.xml $(TEST_DIRS)
# Output test coverage as HTML (into htmlcov directory).
@@ -848,30 +860,24 @@ reprepro: deb-build/$(DEB_NVRA).deb reprepro/conf
amazon-ebs:
cd packaging/packer && $(PACKER) build -only $@ $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
# virtualbox
virtualbox-ovf: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box
# Vagrant box using virtualbox provider
vagrant-virtualbox: packaging/packer/ansible-tower-$(VERSION)-virtualbox.box
packaging/packer/ansible-tower-$(VERSION)-virtualbox.box: packaging/packer/output-virtualbox-iso/centos-7.ovf
cd packaging/packer && $(PACKER) build -only virtualbox-ovf $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
packaging/packer/output-virtualbox-iso/centos-6.ovf:
cd packaging/packer && $(PACKER) build packer-centos-6.json
packaging/packer/output-virtualbox-iso/centos-7.ovf:
cd packaging/packer && $(PACKER) build -only virtualbox-iso packer-centos-7.json
# virtualbox-iso: packaging/packer/output-virtualbox-iso/centos-6.ovf
virtualbox-iso: packaging/packer/output-virtualbox-iso/centos-7.ovf
# vmware
# Vagrant box using VMware provider
vagrant-vmware: packaging/packer/ansible-tower-$(VERSION)-vmware.box
packaging/packer/output-vmware-iso/centos-7.vmx:
cd packaging/packer && $(PACKER) build -only vmware-iso packer-centos-7.json
vmware-iso: packaging/packer/output-vmware-iso/centos-7.vmx
vmware-vmx: packaging/packer/ansible-tower-$(VERSION)-vmx/ansible-tower-$(VERSION).vmx
packaging/packer/ansible-tower-$(VERSION)-vmx/ansible-tower-$(VERSION).vmx: packaging/packer/output-vmware-iso/centos-7.vmx
packaging/packer/ansible-tower-$(VERSION)-vmware.box: packaging/packer/output-vmware-iso/centos-7.vmx
cd packaging/packer && $(PACKER) build -only vmware-vmx $(PACKER_BUILD_OPTS) -var "aws_instance_count=$(AWS_INSTANCE_COUNT)" -var "product_version=$(VERSION)" packer-$(NAME).json
# TODO - figure out how to build the front-end and python requirements with

View File

@@ -5,7 +5,7 @@ import os
import sys
import warnings
__version__ = '3.0.1'
__version__ = '3.0.2'
__all__ = ['__version__']

View File

@@ -19,7 +19,7 @@ from awx.main.utils import get_object_or_400
logger = logging.getLogger('awx.api.permissions')
__all__ = ['ModelAccessPermission', 'JobTemplateCallbackPermission',
'TaskPermission', 'ProjectUpdatePermission']
'TaskPermission', 'ProjectUpdatePermission', 'UserPermission']
class ModelAccessPermission(permissions.BasePermission):
'''
@@ -202,3 +202,10 @@ class ProjectUpdatePermission(ModelAccessPermission):
def check_post_permissions(self, request, view, obj=None):
project = get_object_or_400(view.model, pk=view.kwargs['pk'])
return check_user_access(request.user, view.model, 'start', project)
class UserPermission(ModelAccessPermission):
def check_post_permissions(self, request, view, obj=None):
if request.user.is_superuser:
return True
raise PermissionDenied()

View File

@@ -526,8 +526,10 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
serializer_class = InventorySourceSerializer
elif isinstance(obj, JobTemplate):
serializer_class = JobTemplateSerializer
elif isinstance(obj, SystemJobTemplate):
serializer_class = SystemJobTemplateSerializer
if serializer_class:
serializer = serializer_class(instance=obj)
serializer = serializer_class(instance=obj, context=self.context)
return serializer.to_representation(obj)
else:
return super(UnifiedJobTemplateSerializer, self).to_representation(obj)
@@ -590,7 +592,7 @@ class UnifiedJobSerializer(BaseSerializer):
elif isinstance(obj, SystemJob):
serializer_class = SystemJobSerializer
if serializer_class:
serializer = serializer_class(instance=obj)
serializer = serializer_class(instance=obj, context=self.context)
ret = serializer.to_representation(obj)
else:
ret = super(UnifiedJobSerializer, self).to_representation(obj)
@@ -637,7 +639,7 @@ class UnifiedJobListSerializer(UnifiedJobSerializer):
elif isinstance(obj, SystemJob):
serializer_class = SystemJobListSerializer
if serializer_class:
serializer = serializer_class(instance=obj)
serializer = serializer_class(instance=obj, context=self.context)
ret = serializer.to_representation(obj)
else:
ret = super(UnifiedJobListSerializer, self).to_representation(obj)
@@ -1285,7 +1287,8 @@ class CustomInventoryScriptSerializer(BaseSerializer):
request = self.context.get('request', None)
if request.user not in obj.admin_role and \
not request.user.is_superuser and \
not request.user.is_system_auditor:
not request.user.is_system_auditor and \
not (obj.organization is not None and request.user in obj.organization.auditor_role):
ret['script'] = None
return ret
@@ -1713,19 +1716,21 @@ class CredentialSerializerCreate(CredentialSerializer):
attrs.pop(field)
if not owner_fields:
raise serializers.ValidationError({"detail": "Missing 'user', 'team', or 'organization'."})
elif len(owner_fields) > 1:
raise serializers.ValidationError({"detail": "Expecting exactly one of 'user', 'team', or 'organization'."})
return super(CredentialSerializerCreate, self).validate(attrs)
def create(self, validated_data):
user = validated_data.pop('user', None)
team = validated_data.pop('team', None)
if team:
validated_data['organization'] = team.organization
credential = super(CredentialSerializerCreate, self).create(validated_data)
if user:
credential.admin_role.members.add(user)
if team:
credential.admin_role.parents.add(team.member_role)
if not credential.organization or team.organization.id != credential.organization.id:
raise serializers.ValidationError({"detail": "Credential organization must be set and match before assigning to a team"})
credential.admin_role.parents.add(team.admin_role)
credential.use_role.parents.add(team.member_role)
return credential
@@ -1823,7 +1828,7 @@ class JobTemplateSerializer(UnifiedJobTemplateSerializer, JobOptionsSerializer):
class Meta:
model = JobTemplate
fields = ('*', 'host_config_key', 'ask_variables_on_launch', 'ask_limit_on_launch',
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch',
'ask_tags_on_launch', 'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch',
'ask_credential_on_launch', 'survey_enabled', 'become_enabled', 'allow_simultaneous')
def get_related(self, obj):
@@ -1907,6 +1912,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
passwords_needed_to_start = serializers.ReadOnlyField()
ask_variables_on_launch = serializers.ReadOnlyField()
ask_limit_on_launch = serializers.ReadOnlyField()
ask_skip_tags_on_launch = serializers.ReadOnlyField()
ask_tags_on_launch = serializers.ReadOnlyField()
ask_job_type_on_launch = serializers.ReadOnlyField()
ask_inventory_on_launch = serializers.ReadOnlyField()
@@ -1915,8 +1921,8 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
class Meta:
model = Job
fields = ('*', 'job_template', 'passwords_needed_to_start', 'ask_variables_on_launch',
'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_job_type_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch')
'ask_limit_on_launch', 'ask_tags_on_launch', 'ask_skip_tags_on_launch',
'ask_job_type_on_launch', 'ask_inventory_on_launch', 'ask_credential_on_launch')
def get_related(self, obj):
res = super(JobSerializer, self).get_related(obj)
@@ -1977,7 +1983,7 @@ class JobSerializer(UnifiedJobSerializer, JobOptionsSerializer):
return ret
if 'job_template' in ret and not obj.job_template:
ret['job_template'] = None
if obj.job_template and obj.job_template.survey_enabled and 'extra_vars' in ret:
if 'extra_vars' in ret:
ret['extra_vars'] = obj.display_extra_vars()
return ret
@@ -2280,14 +2286,15 @@ class JobLaunchSerializer(BaseSerializer):
fields = ('can_start_without_user_input', 'passwords_needed_to_start',
'extra_vars', 'limit', 'job_tags', 'skip_tags', 'job_type', 'inventory',
'credential', 'ask_variables_on_launch', 'ask_tags_on_launch',
'ask_job_type_on_launch', 'ask_limit_on_launch',
'ask_skip_tags_on_launch', 'ask_job_type_on_launch', 'ask_limit_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch',
'survey_enabled', 'variables_needed_to_start',
'credential_needed_to_start', 'inventory_needed_to_start',
'job_template_data', 'defaults')
read_only_fields = ('ask_variables_on_launch', 'ask_limit_on_launch',
'ask_tags_on_launch', 'ask_job_type_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch')
read_only_fields = (
'ask_variables_on_launch', 'ask_limit_on_launch', 'ask_tags_on_launch',
'ask_skip_tags_on_launch', 'ask_job_type_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch')
extra_kwargs = {
'credential': {'write_only': True,},
'limit': {'write_only': True,},
@@ -2674,6 +2681,8 @@ class ActivityStreamSerializer(BaseSerializer):
fval = getattr(thisItem, field, None)
if fval is not None:
thisItemDict[field] = fval
if fk == 'group':
thisItemDict['inventory_id'] = getattr(thisItem, 'inventory_id', None)
summary_fields[fk].append(thisItemDict)
except ObjectDoesNotExist:
pass

View File

@@ -8,6 +8,8 @@ The response will include the following fields:
configured to prompt for variables upon launch (boolean, read-only)
* `ask_tags_on_launch`: Flag indicating whether the job_template is
configured to prompt for tags upon launch (boolean, read-only)
* `ask_skip_tags_on_launch`: Flag indicating whether the job_template is
configured to prompt for skip_tags upon launch (boolean, read-only)
* `ask_job_type_on_launch`: Flag indicating whether the job_template is
configured to prompt for job_type upon launch (boolean, read-only)
* `ask_limit_on_launch`: Flag indicating whether the job_template is

View File

@@ -201,7 +201,7 @@ class ApiV1ConfigView(APIView):
'''Return various sitewide configuration settings.'''
license_reader = TaskSerializer()
license_data = license_reader.from_database(show_key=request.user.is_superuser)
license_data = license_reader.from_database(show_key=request.user.is_superuser or request.user.is_system_auditor)
if license_data and 'features' in license_data and 'activity_streams' in license_data['features']:
license_data['features']['activity_streams'] &= tower_settings.ACTIVITY_STREAM_ENABLED
@@ -225,7 +225,10 @@ class ApiV1ConfigView(APIView):
user_ldap_fields.extend(getattr(settings, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}).keys())
data['user_ldap_fields'] = user_ldap_fields
if request.user.is_superuser or Organization.accessible_objects(request.user, 'admin_role').exists():
if request.user.is_superuser \
or request.user.is_system_auditor \
or Organization.accessible_objects(request.user, 'admin_role').exists() \
or Organization.accessible_objects(request.user, 'auditor_role').exists():
data.update(dict(
project_base_dir = settings.PROJECTS_ROOT,
project_local_paths = Project.get_local_path_choices(),
@@ -876,12 +879,19 @@ class TeamRolesList(SubListCreateAttachDetachAPIView):
data = dict(msg="Role 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
role = Role.objects.get(pk=sub_id)
content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == content_type:
role = get_object_or_400(Role, pk=sub_id)
org_content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == org_content_type:
data = dict(msg="You cannot assign an Organization role as a child role for a Team.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
team = get_object_or_404(Team, pk=self.kwargs['pk'])
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if not role.content_object.organization or role.content_object.organization.id != team.organization.id:
data = dict(msg="You cannot grant credential access to a team when the Organization field isn't set, or belongs to a different organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(TeamRolesList, self).post(request, *args, **kwargs)
class TeamObjectRolesList(SubListAPIView):
@@ -1143,6 +1153,7 @@ class UserList(ListCreateAPIView):
model = User
serializer_class = UserSerializer
permission_classes = (UserPermission,)
def post(self, request, *args, **kwargs):
ret = super(UserList, self).post( request, *args, **kwargs)
@@ -1205,7 +1216,24 @@ class UserRolesList(SubListCreateAttachDetachAPIView):
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if sub_id == self.request.user.admin_role.pk:
raise PermissionDenied('You may not remove your own admin_role.')
raise PermissionDenied('You may not perform any action with your own admin_role.')
user = get_object_or_400(User, pk=self.kwargs['pk'])
role = get_object_or_400(Role, pk=sub_id)
user_content_type = ContentType.objects.get_for_model(User)
if role.content_type == user_content_type:
raise PermissionDenied('You may not change the membership of a users admin_role')
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if role.content_object.organization and user not in role.content_object.organization.member_role:
data = dict(msg="You cannot grant credential access to a user not in the credentials' organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if not role.content_object.organization and not request.user.is_superuser:
data = dict(msg="You cannot grant private credential access to another user")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(UserRolesList, self).post(request, *args, **kwargs)
@@ -1291,7 +1319,7 @@ class UserDetail(RetrieveUpdateDestroyAPIView):
can_admin = request.user.can_access(User, 'admin', obj, request.data)
su_only_edit_fields = ('is_superuser', 'is_system_auditor')
admin_only_edit_fields = ('last_name', 'first_name', 'username', 'is_active')
admin_only_edit_fields = ('username', 'is_active')
fields_to_check = ()
if not request.user.is_superuser:
@@ -1384,8 +1412,8 @@ class TeamCredentialsList(SubListCreateAPIView):
self.check_parent_access(team)
visible_creds = Credential.accessible_objects(self.request.user, 'read_role')
team_creds = Credential.objects.filter(admin_role__parents=team.member_role)
return team_creds & visible_creds
team_creds = Credential.objects.filter(Q(use_role__parents=team.member_role) | Q(admin_role__parents=team.member_role))
return (team_creds & visible_creds).distinct()
class OrganizationCredentialList(SubListCreateAPIView):
@@ -3623,7 +3651,6 @@ class RoleDetail(RetrieveAPIView):
model = Role
serializer_class = RoleSerializer
permission_classes = (IsAuthenticated,)
new_in_300 = True
@@ -3646,6 +3673,26 @@ class RoleUsersList(SubListCreateAttachDetachAPIView):
if not sub_id:
data = dict(msg="User 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
user = get_object_or_400(User, pk=sub_id)
role = self.get_parent_object()
if role == self.request.user.admin_role:
raise PermissionDenied('You may not perform any action with your own admin_role.')
user_content_type = ContentType.objects.get_for_model(User)
if role.content_type == user_content_type:
raise PermissionDenied('You may not change the membership of a users admin_role')
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if role.content_object.organization and user not in role.content_object.organization.member_role:
data = dict(msg="You cannot grant credential access to a user not in the credentials' organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
if not role.content_object.organization and not request.user.is_superuser:
data = dict(msg="You cannot grant private credential access to another user")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
return super(RoleUsersList, self).post(request, *args, **kwargs)
@@ -3670,13 +3717,20 @@ class RoleTeamsList(SubListAPIView):
data = dict(msg="Team 'id' field is missing.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
team = get_object_or_400(Team, pk=sub_id)
role = Role.objects.get(pk=self.kwargs['pk'])
content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == content_type:
organization_content_type = ContentType.objects.get_for_model(Organization)
if role.content_type == organization_content_type:
data = dict(msg="You cannot assign an Organization role as a child role for a Team.")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
team = Team.objects.get(pk=sub_id)
credential_content_type = ContentType.objects.get_for_model(Credential)
if role.content_type == credential_content_type:
if not role.content_object.organization or role.content_object.organization.id != team.organization.id:
data = dict(msg="You cannot grant credential access to a team when the Organization field isn't set, or belongs to a different organization")
return Response(data, status=status.HTTP_400_BAD_REQUEST)
action = 'attach'
if request.data.get('disassociate', None):
action = 'unattach'

View File

@@ -428,8 +428,8 @@ class HostAccess(BaseAccess):
return obj and self.user in obj.inventory.read_role
def can_add(self, data):
if not data or 'inventory' not in data:
return False
if not data: # So the browseable API will work
return Inventory.accessible_objects(self.user, 'admin_role').exists()
# Checks for admin or change permission on inventory.
inventory_pk = get_pk_from_dict(data, 'inventory')
@@ -654,23 +654,14 @@ class CredentialAccess(BaseAccess):
if not obj:
return False
# Check access to organizations
organization_pk = get_pk_from_dict(data, 'organization')
if data and 'organization' in data and organization_pk != getattr(obj, 'organization_id', None):
if organization_pk:
# admin permission to destination organization is mandatory
new_organization_obj = get_object_or_400(Organization, pk=organization_pk)
if self.user not in new_organization_obj.admin_role:
return False
# admin permission to existing organization is also mandatory
if obj.organization:
if self.user not in obj.organization.admin_role:
return False
if obj.organization:
if self.user in obj.organization.admin_role:
return True
# Cannot change the organization for a credential after it's been created
if data and 'organization' in data:
organization_pk = get_pk_from_dict(data, 'organization')
if (organization_pk and (not obj.organization or organization_pk != obj.organization.id)) \
or (not organization_pk and obj.organization):
return False
print(self.user in obj.admin_role)
return self.user in obj.admin_role
def can_delete(self, obj):
@@ -981,8 +972,11 @@ class JobTemplateAccess(BaseAccess):
field_whitelist = [
'name', 'description', 'forks', 'limit', 'verbosity', 'extra_vars',
'job_tags', 'force_handlers', 'skip_tags', 'ask_variables_on_launch',
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_inventory_on_launch',
'ask_credential_on_launch', 'survey_enabled'
'ask_tags_on_launch', 'ask_job_type_on_launch', 'ask_skip_tags_on_launch',
'ask_inventory_on_launch', 'ask_credential_on_launch', 'survey_enabled',
# These fields are ignored, but it is convenient for QA to allow clients to post them
'last_job_run', 'created', 'modified',
]
for k, v in data.items():
@@ -1089,7 +1083,8 @@ class JobAccess(BaseAccess):
def can_delete(self, obj):
if obj.inventory is not None and self.user in obj.inventory.organization.admin_role:
return True
if obj.project is not None and self.user in obj.project.organization.admin_role:
if (obj.project is not None and obj.project.organization is not None and
self.user in obj.project.organization.admin_role):
return True
return False
@@ -1299,9 +1294,11 @@ class UnifiedJobTemplateAccess(BaseAccess):
project_qs = self.user.get_queryset(Project).filter(scm_type__in=[s[0] for s in Project.SCM_TYPE_CHOICES])
inventory_source_qs = self.user.get_queryset(InventorySource).filter(source__in=CLOUD_INVENTORY_SOURCES)
job_template_qs = self.user.get_queryset(JobTemplate)
system_job_template_qs = self.user.get_queryset(SystemJobTemplate)
qs = qs.filter(Q(Project___in=project_qs) |
Q(InventorySource___in=inventory_source_qs) |
Q(JobTemplate___in=job_template_qs))
Q(JobTemplate___in=job_template_qs) |
Q(systemjobtemplate__in=system_job_template_qs))
qs = qs.select_related(
'created_by',
'modified_by',
@@ -1569,21 +1566,22 @@ class ActivityStreamAccess(BaseAccess):
inventory_set = Inventory.accessible_objects(self.user, 'read_role')
credential_set = Credential.accessible_objects(self.user, 'read_role')
organization_set = Organization.accessible_objects(self.user, 'read_role')
admin_of_orgs = Organization.accessible_objects(self.user, 'admin_role')
group_set = Group.objects.filter(inventory__in=inventory_set)
auditing_orgs = (
Organization.accessible_objects(self.user, 'admin_role') |
Organization.accessible_objects(self.user, 'auditor_role')
).distinct().values_list('id', flat=True)
project_set = Project.accessible_objects(self.user, 'read_role')
jt_set = JobTemplate.accessible_objects(self.user, 'read_role')
team_set = Team.accessible_objects(self.user, 'read_role')
return qs.filter(
Q(ad_hoc_command__inventory__in=inventory_set) |
Q(user__in=organization_set.values('member_role__members')) |
Q(user__in=auditing_orgs.values('member_role__members')) |
Q(user=self.user) |
Q(organization__in=organization_set) |
Q(organization__in=auditing_orgs) |
Q(inventory__in=inventory_set) |
Q(host__inventory__in=inventory_set) |
Q(group__in=group_set) |
Q(group__inventory__in=inventory_set) |
Q(inventory_source__inventory__in=inventory_set) |
Q(inventory_update__inventory_source__inventory__in=inventory_set) |
Q(credential__in=credential_set) |
@@ -1592,10 +1590,10 @@ class ActivityStreamAccess(BaseAccess):
Q(project_update__project__in=project_set) |
Q(job_template__in=jt_set) |
Q(job__job_template__in=jt_set) |
Q(notification_template__organization__in=admin_of_orgs) |
Q(notification__notification_template__organization__in=admin_of_orgs) |
Q(label__organization__in=organization_set) |
Q(role__in=Role.visible_roles(self.user))
Q(notification_template__organization__in=auditing_orgs) |
Q(notification__notification_template__organization__in=auditing_orgs) |
Q(label__organization__in=auditing_orgs) |
Q(role__in=Role.visible_roles(self.user) if auditing_orgs else [])
).distinct()
def can_add(self, data):
@@ -1667,14 +1665,8 @@ class RoleAccess(BaseAccess):
if self.user.is_superuser or self.user.is_system_auditor:
return True
if obj.object_id:
sister_roles = Role.objects.filter(
content_type = obj.content_type,
object_id = obj.object_id
)
else:
sister_roles = obj
return self.user.roles.filter(descendents__in=sister_roles).exists()
return Role.filter_visible_roles(
self.user, Role.objects.filter(pk=obj.id)).exists()
def can_add(self, obj, data):
# Unsupported for now

View File

@@ -54,10 +54,6 @@ class AutoOneToOneField(models.OneToOneField):
AutoSingleRelatedObjectDescriptor(related))
def resolve_role_field(obj, field):
ret = []
@@ -71,8 +67,8 @@ def resolve_role_field(obj, field):
return []
if len(field_components) == 1:
Role_ = get_current_apps().get_model('main', 'Role')
if type(obj) is not Role_:
role_cls = str(get_current_apps().get_model('main', 'Role'))
if not str(type(obj)) == role_cls:
raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj))))
ret.append(obj.id)
else:

View File

@@ -22,6 +22,7 @@ import yaml
from django.conf import settings
from django.core.management.base import NoArgsCommand, CommandError
from django.db import connection, transaction
from django.utils.encoding import smart_text
# AWX
from awx.main.models import * # noqa
@@ -606,7 +607,7 @@ class Command(NoArgsCommand):
break
instance_id = from_dict.get(key, default)
from_dict = instance_id
return instance_id
return smart_text(instance_id)
def _get_enabled(self, from_dict, default=None):
'''

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import awx.main.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0028_v300_org_team_cascade'),
]
operations = [
migrations.AddField(
model_name='jobtemplate',
name='ask_skip_tags_on_launch',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0029_v302_add_ask_skip_tags'),
]
operations = [
migrations.AddField(
model_name='job',
name='survey_passwords',
field=jsonfield.fields.JSONField(default={}, editable=False, blank=True),
),
]

View File

@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from awx.main.migrations import _save_password_keys
from awx.main.migrations import _migration_utils as migration_utils
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('main', '0030_v302_job_survey_passwords'),
]
operations = [
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
migrations.RunPython(_save_password_keys.migrate_survey_passwords),
]

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations
from awx.main.migrations import _rbac as rbac
from awx.main.migrations import _migration_utils as migration_utils
import awx.main.fields
class Migration(migrations.Migration):
dependencies = [
('main', '0031_v302_migrate_survey_passwords'),
]
operations = [
migrations.RunPython(migration_utils.set_current_apps_for_migrations),
migrations.AlterField(
model_name='credential',
name='admin_role',
field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_administrator', b'organization.admin_role'], to='main.Role', null=b'True'),
),
migrations.AlterField(
model_name='credential',
name='use_role',
field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'admin_role'], to='main.Role', null=b'True'),
),
migrations.RunPython(rbac.infer_credential_org_from_team),
migrations.RunPython(rbac.rebuild_role_hierarchy),
]

View File

@@ -2,7 +2,9 @@ import logging
from time import time
from django.utils.encoding import smart_text
from django.db import transaction
from django.db.models import Q
from django.db.utils import IntegrityError
from collections import defaultdict
from awx.main.utils import getattrd
@@ -490,3 +492,11 @@ def rebuild_role_hierarchy(apps, schema_editor):
logger.info('Done.')
def infer_credential_org_from_team(apps, schema_editor):
Credential = apps.get_model('main', "Credential")
for cred in Credential.objects.exclude(deprecated_team__isnull=True):
try:
with transaction.atomic():
_update_credential_parents(cred.deprecated_team.organization, cred)
except IntegrityError:
logger.info("Organization<{}> credential for old Team<{}> credential already created".format(cred.deprecated_team.organization.pk, cred.pk))

View File

@@ -0,0 +1,27 @@
def survey_password_variables(survey_spec):
vars = []
# Get variables that are type password
if 'spec' not in survey_spec:
return vars
for survey_element in survey_spec['spec']:
if 'type' in survey_element and survey_element['type'] == 'password':
vars.append(survey_element['variable'])
return vars
def migrate_survey_passwords(apps, schema_editor):
'''Take the output of the Job Template password list for all that
have a survey enabled, and then save it into the job model.
'''
Job = apps.get_model('main', 'Job')
for job in Job.objects.iterator():
if not job.job_template:
continue
jt = job.job_template
if jt.survey_spec is not None and jt.survey_enabled:
password_list = survey_password_variables(jt.survey_spec)
hide_password_dict = {}
for password in password_list:
hide_password_dict[password] = "$encrypted$"
job.survey_passwords = hide_password_dict
job.save()

View File

@@ -215,11 +215,11 @@ class Credential(PasswordFieldsModel, CommonModelNameNotUnique, ResourceMixin):
admin_role = ImplicitRoleField(
parent_role=[
'singleton:' + ROLE_SINGLETON_SYSTEM_ADMINISTRATOR,
'organization.admin_role',
],
)
use_role = ImplicitRoleField(
parent_role=[
'organization.admin_role',
'admin_role',
]
)

View File

@@ -6,6 +6,7 @@ import hmac
import json
import yaml
import logging
import time
from urlparse import urljoin
# Django
@@ -26,7 +27,7 @@ from awx.main.models.unified_jobs import * # noqa
from awx.main.models.notifications import NotificationTemplate
from awx.main.utils import decrypt_field, ignore_inventory_computed_fields
from awx.main.utils import emit_websocket_notification
from awx.main.redact import PlainTextCleaner, REPLACE_STR
from awx.main.redact import PlainTextCleaner
from awx.main.conf import tower_settings
from awx.main.fields import ImplicitRoleField
from awx.main.models.mixins import ResourceMixin
@@ -199,6 +200,10 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
blank=True,
default=False,
)
ask_skip_tags_on_launch = models.BooleanField(
blank=True,
default=False,
)
ask_job_type_on_launch = models.BooleanField(
blank=True,
default=False,
@@ -244,7 +249,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
'playbook', 'credential', 'cloud_credential', 'network_credential', 'forks', 'schedule',
'limit', 'verbosity', 'job_tags', 'extra_vars', 'launch_type',
'force_handlers', 'skip_tags', 'start_at_task', 'become_enabled',
'labels',]
'labels', 'survey_passwords']
def resource_validation_data(self):
'''
@@ -418,7 +423,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
extra_vars=self.ask_variables_on_launch,
limit=self.ask_limit_on_launch,
job_tags=self.ask_tags_on_launch,
skip_tags=self.ask_tags_on_launch,
skip_tags=self.ask_skip_tags_on_launch,
job_type=self.ask_job_type_on_launch,
inventory=self.ask_inventory_on_launch,
credential=self.ask_credential_on_launch
@@ -442,11 +447,21 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, ResourceMixin):
if field == 'extra_vars' and self.survey_enabled and self.survey_spec:
# Accept vars defined in the survey and no others
survey_vars = [question['variable'] for question in self.survey_spec.get('spec', [])]
for key in kwargs[field]:
extra_vars = kwargs[field]
if isinstance(extra_vars, basestring):
try:
extra_vars = json.loads(extra_vars)
except (ValueError, TypeError):
try:
extra_vars = yaml.safe_load(extra_vars)
assert isinstance(extra_vars, dict)
except (yaml.YAMLError, TypeError, AttributeError, AssertionError):
extra_vars = {}
for key in extra_vars:
if key in survey_vars:
prompted_fields[field][key] = kwargs[field][key]
prompted_fields[field][key] = extra_vars[key]
else:
ignored_fields[field][key] = kwargs[field][key]
ignored_fields[field][key] = extra_vars[key]
else:
ignored_fields[field] = kwargs[field]
@@ -509,6 +524,11 @@ class Job(UnifiedJob, JobOptions):
editable=False,
through='JobHostSummary',
)
survey_passwords = JSONField(
blank=True,
default={},
editable=False,
)
@classmethod
def _get_parent_field_name(cls):
@@ -550,6 +570,12 @@ class Job(UnifiedJob, JobOptions):
return self.job_template.ask_tags_on_launch
return False
@property
def ask_skip_tags_on_launch(self):
if self.job_template is not None:
return self.job_template.ask_skip_tags_on_launch
return False
@property
def ask_job_type_on_launch(self):
if self.job_template is not None:
@@ -670,9 +696,17 @@ class Job(UnifiedJob, JobOptions):
dependencies.append(source.create_inventory_update(launch_type='dependency'))
return dependencies
def notification_data(self):
def notification_data(self, block=5):
data = super(Job, self).notification_data()
all_hosts = {}
# NOTE: Probably related to job event slowness, remove at some point -matburt
if block:
summaries = self.job_host_summaries.all()
while block > 0 and not len(summaries):
time.sleep(1)
block -= 1
else:
summaries = self.job_host_summaries.all()
for h in self.job_host_summaries.all():
all_hosts[h.host_name] = dict(failed=h.failed,
changed=h.changed,
@@ -711,16 +745,12 @@ class Job(UnifiedJob, JobOptions):
'''
Hides fields marked as passwords in survey.
'''
if self.extra_vars and self.job_template and self.job_template.survey_enabled:
try:
extra_vars = json.loads(self.extra_vars)
for key in self.job_template.survey_password_variables():
if key in extra_vars:
extra_vars[key] = REPLACE_STR
return json.dumps(extra_vars)
except ValueError:
pass
return self.extra_vars
if self.survey_passwords:
extra_vars = json.loads(self.extra_vars)
extra_vars.update(self.survey_passwords)
return json.dumps(extra_vars)
else:
return self.extra_vars
def _survey_search_and_replace(self, content):
# Use job template survey spec to identify password fields.

View File

@@ -32,7 +32,7 @@ from djcelery.models import TaskMeta
from awx.main.models.base import * # noqa
from awx.main.models.schedules import Schedule
from awx.main.utils import decrypt_field, emit_websocket_notification, _inventory_updates
from awx.main.redact import UriCleaner
from awx.main.redact import UriCleaner, REPLACE_STR
__all__ = ['UnifiedJobTemplate', 'UnifiedJob']
@@ -81,6 +81,9 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
ALL_STATUS_CHOICES = OrderedDict(PROJECT_STATUS_CHOICES + INVENTORY_SOURCE_STATUS_CHOICES + JOB_TEMPLATE_STATUS_CHOICES + DEPRECATED_STATUS_CHOICES).items()
# NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229
_base_manager = models.Manager()
class Meta:
app_label = 'main'
unique_together = [('polymorphic_ctype', 'name')]
@@ -343,6 +346,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
create_kwargs[field_name] = getattr(self, field_name)
new_kwargs = self._update_unified_job_kwargs(**create_kwargs)
unified_job = unified_job_class(**new_kwargs)
# For JobTemplate-based jobs with surveys, save list for perma-redaction
if (hasattr(self, 'survey_spec') and getattr(self, 'survey_enabled', False) and
not getattr(unified_job, 'survey_passwords', False)):
password_list = self.survey_password_variables()
hide_password_dict = {}
for password in password_list:
hide_password_dict[password] = REPLACE_STR
unified_job.survey_passwords = hide_password_dict
unified_job.save()
for field_name, src_field_value in m2m_fields.iteritems():
dest_field = getattr(unified_job, field_name)
@@ -367,6 +378,9 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
PASSWORD_FIELDS = ('start_args',)
# NOTE: Working around a django-polymorphic issue: https://github.com/django-polymorphic/django-polymorphic/issues/229
_base_manager = models.Manager()
class Meta:
app_label = 'main'

View File

@@ -214,15 +214,18 @@ def handle_work_success(self, result, task_actual):
friendly_name = "System Job"
else:
return
notification_body = instance.notification_data()
notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name,
task_actual['id'],
smart_str(instance_name),
notification_body['url'])
notification_body['friendly_name'] = friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in set(notification_templates.get('success', []) + notification_templates.get('any', []))],
job_id=task_actual['id'])
all_notification_templates = set(notification_templates.get('success', []) + notification_templates.get('any', []))
if len(all_notification_templates):
notification_body = instance.notification_data()
notification_subject = "{} #{} '{}' succeeded on Ansible Tower: {}".format(friendly_name,
task_actual['id'],
smart_str(instance_name),
notification_body['url'])
notification_body['friendly_name'] = friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in all_notification_templates],
job_id=task_actual['id'])
@task(bind=True)
def handle_work_error(self, task_id, subtasks=None):
@@ -277,15 +280,18 @@ def handle_work_error(self, task_id, subtasks=None):
(first_task_type, first_task_name, first_task_id)
instance.save()
instance.socketio_emit_status("failed")
notification_body = first_task.notification_data()
notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name,
first_task_id,
smart_str(first_task_name),
notification_body['url'])
notification_body['friendly_name'] = first_task_friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in set(notification_templates.get('error', []) + notification_templates.get('any', []))],
job_id=first_task_id)
all_notification_templates = set(notification_templates.get('error', []) + notification_templates.get('any', []))
if len(all_notification_templates):
notification_body = first_task.notification_data()
notification_subject = "{} #{} '{}' failed on Ansible Tower: {}".format(first_task_friendly_name,
first_task_id,
smart_str(first_task_name),
notification_body['url'])
notification_body['friendly_name'] = first_task_friendly_name
send_notifications.delay([n.generate_notification(notification_subject, notification_body).id
for n in all_notification_templates],
job_id=first_task_id)
@task()
@@ -1313,9 +1319,14 @@ class RunInventoryUpdate(BaseTask):
credential = inventory_update.credential
if credential:
cp.set(section, 'hostname', credential.host)
cp.set(section, 'url', credential.host)
cp.set(section, 'username', credential.username)
cp.set(section, 'password', decrypt_field(credential, 'password'))
cp.set(section, 'ssl_verify', "false")
section = 'cache'
cp.add_section(section)
cp.set(section, 'max_age', "0")
elif inventory_update.source == 'azure_rm':
section = 'azure'

View File

@@ -26,16 +26,16 @@ def survey_spec_factory():
return create_survey_spec
@pytest.fixture
def job_with_secret_key_factory(job_template_factory):
def job_template_with_survey_passwords_factory(job_template_factory):
def rf(persisted):
"Returns job with linked JT survey with password survey questions"
objects = job_template_factory('jt', organization='org1', survey=[
{'variable': 'submitter_email', 'type': 'text', 'default': 'foobar@redhat.com'},
{'variable': 'secret_key', 'default': '6kQngg3h8lgiSTvIEb21', 'type': 'password'},
{'variable': 'SSN', 'type': 'password'}], jobs=[1], persisted=persisted)
return objects.jobs[1]
{'variable': 'SSN', 'type': 'password'}], persisted=persisted)
return objects.job_template
return rf
@pytest.fixture
def job_with_secret_key_unit(job_with_secret_key_factory):
return job_with_secret_key_factory(persisted=False)
def job_template_with_survey_passwords_unit(job_template_with_survey_passwords_factory):
return job_template_with_survey_passwords_factory(persisted=False)

View File

@@ -68,7 +68,7 @@ def test_create_user_credential_via_user_credentials_list_xfail(post, alice, bob
#
@pytest.mark.django_db
def test_create_team_credential(post, get, team, org_admin, team_member):
def test_create_team_credential(post, get, team, organization, org_admin, team_member):
response = post(reverse('api:credential_list'), {
'team': team.id,
'name': 'Some name',
@@ -80,6 +80,9 @@ def test_create_team_credential(post, get, team, org_admin, team_member):
assert response.status_code == 200
assert response.data['count'] == 1
# Assure that credential's organization is implictly set to team's org
assert response.data['results'][0]['summary_fields']['organization']['id'] == team.organization.id
@pytest.mark.django_db
def test_create_team_credential_via_team_credentials_list(post, get, team, org_admin, team_member):
response = post(reverse('api:team_credentials_list', args=(team.pk,)), {
@@ -94,25 +97,159 @@ def test_create_team_credential_via_team_credentials_list(post, get, team, org_a
assert response.data['count'] == 1
@pytest.mark.django_db
def test_create_team_credential_by_urelated_user_xfail(post, team, alice, team_member):
def test_create_team_credential_by_urelated_user_xfail(post, team, organization, alice, team_member):
response = post(reverse('api:credential_list'), {
'team': team.id,
'organization': organization.id,
'name': 'Some name',
'username': 'someusername'
}, alice)
assert response.status_code == 403
@pytest.mark.django_db
def test_create_team_credential_by_team_member_xfail(post, team, alice, team_member):
def test_create_team_credential_by_team_member_xfail(post, team, organization, alice, team_member):
# Members can't add credentials, only org admins.. for now?
response = post(reverse('api:credential_list'), {
'team': team.id,
'organization': organization.id,
'name': 'Some name',
'username': 'someusername'
}, team_member)
assert response.status_code == 403
#
# Permission granting
#
@pytest.mark.django_db
def test_grant_org_credential_to_org_user_through_role_users(post, credential, organization, org_admin, org_member):
credential.organization = organization
credential.save()
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': org_member.id
}, org_admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_org_credential_to_org_user_through_user_roles(post, credential, organization, org_admin, org_member):
credential.organization = organization
credential.save()
response = post(reverse('api:user_roles_list', args=(org_member.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_org_credential_to_non_org_user_through_role_users(post, credential, organization, org_admin, alice):
credential.organization = organization
credential.save()
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': alice.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_org_credential_to_non_org_user_through_user_roles(post, credential, organization, org_admin, alice):
credential.organization = organization
credential.save()
response = post(reverse('api:user_roles_list', args=(alice.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_private_credential_to_user_through_role_users(post, credential, alice, bob):
# normal users can't do this
credential.admin_role.members.add(alice)
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': bob.id
}, alice)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_private_credential_to_org_user_through_role_users(post, credential, org_admin, org_member):
# org admins can't either
credential.admin_role.members.add(org_admin)
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': org_member.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_sa_grant_private_credential_to_user_through_role_users(post, credential, admin, bob):
# but system admins can
response = post(reverse('api:role_users_list', args=(credential.use_role.id,)), {
'id': bob.id
}, admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_private_credential_to_user_through_user_roles(post, credential, alice, bob):
# normal users can't do this
credential.admin_role.members.add(alice)
response = post(reverse('api:user_roles_list', args=(bob.id,)), {
'id': credential.use_role.id
}, alice)
assert response.status_code == 400
@pytest.mark.django_db
def test_grant_private_credential_to_org_user_through_user_roles(post, credential, org_admin, org_member):
# org admins can't either
credential.admin_role.members.add(org_admin)
response = post(reverse('api:user_roles_list', args=(org_member.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_sa_grant_private_credential_to_user_through_user_roles(post, credential, admin, bob):
# but system admins can
response = post(reverse('api:user_roles_list', args=(bob.id,)), {
'id': credential.use_role.id
}, admin)
assert response.status_code == 204
@pytest.mark.django_db
def test_grant_org_credential_to_team_through_role_teams(post, credential, organization, org_admin, org_auditor, team):
assert org_auditor not in credential.read_role
credential.organization = organization
credential.save()
response = post(reverse('api:role_teams_list', args=(credential.use_role.id,)), {
'id': team.id
}, org_admin)
assert response.status_code == 204
assert org_auditor in credential.read_role
@pytest.mark.django_db
def test_grant_org_credential_to_team_through_team_roles(post, credential, organization, org_admin, org_auditor, team):
assert org_auditor not in credential.read_role
credential.organization = organization
credential.save()
response = post(reverse('api:team_roles_list', args=(team.id,)), {
'id': credential.use_role.id
}, org_admin)
assert response.status_code == 204
assert org_auditor in credential.read_role
@pytest.mark.django_db
def test_sa_grant_private_credential_to_team_through_role_teams(post, credential, admin, team):
# not even a system admin can grant a private cred to a team though
response = post(reverse('api:role_teams_list', args=(credential.use_role.id,)), {
'id': team.id
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_sa_grant_private_credential_to_team_through_team_roles(post, credential, admin, team):
# not even a system admin can grant a private cred to a team though
response = post(reverse('api:role_teams_list', args=(team.id,)), {
'id': credential.use_role.id
}, admin)
assert response.status_code == 400
#
# organization credentials
@@ -177,6 +314,37 @@ def test_list_created_org_credentials(post, get, organization, org_admin, org_me
assert response.data['count'] == 0
@pytest.mark.django_db
def test_cant_change_organization(patch, credential, organization, org_admin):
credential.organization = organization
credential.save()
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name',
}, org_admin)
assert response.status_code == 200
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name2',
'organization': organization.id, # fine for it to be the same
}, org_admin)
assert response.status_code == 200
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name3',
'organization': None
}, org_admin)
assert response.status_code == 403
@pytest.mark.django_db
def test_cant_add_organization(patch, credential, organization, org_admin):
assert credential.organization is None
response = patch(reverse('api:credential_detail', args=(organization.id,)), {
'name': 'Some new name',
'organization': organization.id
}, org_admin)
assert response.status_code == 403
#
# Openstack Credentials
@@ -224,33 +392,3 @@ def test_create_credential_missing_user_team_org_xfail(post, admin):
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_create_credential_with_user_and_org_xfail(post, organization, admin):
# Can only specify one of user, team, or organization
response = post(reverse('api:credential_list'), {
'name': 'Some name',
'username': 'someusername',
'user': admin.id,
'organization': organization.id,
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_create_credential_with_team_and_org_xfail(post, organization, team, admin):
response = post(reverse('api:credential_list'), {
'name': 'Some name',
'username': 'someusername',
'organization': organization.id,
'team': team.id,
}, admin)
assert response.status_code == 400
@pytest.mark.django_db
def test_create_credential_with_user_and_team_xfail(post, team, admin):
response = post(reverse('api:credential_list'), {
'name': 'Some name',
'username': 'someusername',
'user': admin.id,
'team': team.id,
}, admin)
assert response.status_code == 400

View File

@@ -37,6 +37,7 @@ def job_template_prompts(project, inventory, machine_credential):
name='deploy-job-template',
ask_variables_on_launch=on_off,
ask_tags_on_launch=on_off,
ask_skip_tags_on_launch=on_off,
ask_job_type_on_launch=on_off,
ask_inventory_on_launch=on_off,
ask_limit_on_launch=on_off,
@@ -54,6 +55,7 @@ def job_template_prompts_null(project):
name='deploy-job-template',
ask_variables_on_launch=True,
ask_tags_on_launch=True,
ask_skip_tags_on_launch=True,
ask_job_type_on_launch=True,
ask_inventory_on_launch=True,
ask_limit_on_launch=True,
@@ -113,6 +115,20 @@ def test_job_accept_prompted_vars(runtime_data, job_template_prompts, post, admi
mock_job.signal_start.assert_called_once_with(**runtime_data)
@pytest.mark.django_db
@pytest.mark.job_runtime_vars
def test_job_accept_null_tags(job_template_prompts, post, admin_user, mocker):
job_template = job_template_prompts(True)
mock_job = mocker.MagicMock(spec=Job, id=968)
with mocker.patch('awx.main.models.unified_jobs.UnifiedJobTemplate.create_unified_job', return_value=mock_job):
with mocker.patch('awx.api.serializers.JobSerializer.to_representation'):
post(reverse('api:job_template_launch', args=[job_template.pk]),
{'job_tags': '', 'skip_tags': ''}, admin_user, expect=201)
mock_job.signal_start.assert_called_once_with(job_tags='', skip_tags='')
@pytest.mark.django_db
@pytest.mark.job_runtime_vars
def test_job_accept_prompted_vars_null(runtime_data, job_template_prompts_null, post, rando, mocker):

View File

@@ -3,12 +3,14 @@ import mock
# AWX
from awx.api.serializers import JobTemplateSerializer, JobLaunchSerializer
from awx.main.models.jobs import JobTemplate
from awx.main.models.jobs import JobTemplate, Job
from awx.main.models.projects import ProjectOptions
from awx.main.migrations import _save_password_keys as save_password_keys
# Django
from django.test.client import RequestFactory
from django.core.urlresolvers import reverse
from django.apps import apps
@property
def project_playbooks(self):
@@ -103,9 +105,10 @@ def test_edit_nonsenstive(patch, job_template_factory, alice):
'extra_vars': '--',
'job_tags': 'sometags',
'force_handlers': True,
'skip_tags': True,
'skip_tags': 'thistag,thattag',
'ask_variables_on_launch':True,
'ask_tags_on_launch':True,
'ask_skip_tags_on_launch':True,
'ask_job_type_on_launch':True,
'ask_inventory_on_launch':True,
'ask_credential_on_launch': True,
@@ -347,3 +350,20 @@ def test_disallow_template_delete_on_running_job(job_template_factory, delete, a
objects.job_template.create_unified_job()
delete_response = delete(reverse('api:job_template_detail', args=[objects.job_template.pk]), user=admin_user)
assert delete_response.status_code == 409
@pytest.mark.django_db
def test_save_survey_passwords_to_job(job_template_with_survey_passwords):
"""Test that when a new job is created, the survey_passwords field is
given all of the passwords that exist in the JT survey"""
job = job_template_with_survey_passwords.create_unified_job()
assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'}
@pytest.mark.django_db
def test_save_survey_passwords_on_migration(job_template_with_survey_passwords):
"""Test that when upgrading to 3.0.2, the jobs connected to a JT that has
a survey with passwords in it, the survey passwords get saved to the
job survey_passwords field."""
Job.objects.create(job_template=job_template_with_survey_passwords)
save_password_keys.migrate_survey_passwords(apps, None)
job = job_template_with_survey_passwords.jobs.all()[0]
assert job.survey_passwords == {'SSN': '$encrypted$', 'secret_key': '$encrypted$'}

View File

@@ -193,7 +193,8 @@ def test_launch_with_non_empty_survey_spec_no_license(job_template_factory, post
@pytest.mark.django_db
@pytest.mark.survey
def test_redact_survey_passwords_in_activity_stream(job_with_secret_key):
def test_redact_survey_passwords_in_activity_stream(job_template_with_survey_passwords):
job_template_with_survey_passwords.create_unified_job()
AS_record = ActivityStream.objects.filter(object1='job').all()[0]
changes_dict = json.loads(AS_record.changes)
extra_vars = json.loads(changes_dict['extra_vars'])

View File

@@ -160,7 +160,7 @@ def organization(instance):
@pytest.fixture
def credential():
return Credential.objects.create(kind='aws', name='test-cred')
return Credential.objects.create(kind='aws', name='test-cred', username='something', password='secret')
@pytest.fixture
def machine_credential():
@@ -168,7 +168,7 @@ def machine_credential():
@pytest.fixture
def org_credential(organization):
return Credential.objects.create(kind='aws', name='test-cred', organization=organization)
return Credential.objects.create(kind='aws', name='test-cred', username='something', password='secret', organization=organization)
@pytest.fixture
def inventory(organization):
@@ -206,8 +206,8 @@ def notification(notification_template):
subject='email subject')
@pytest.fixture
def job_with_secret_key(job_with_secret_key_factory):
return job_with_secret_key_factory(persisted=True)
def job_template_with_survey_passwords(job_template_with_survey_passwords_factory):
return job_template_with_survey_passwords_factory(persisted=True)
@pytest.fixture
def admin(user):

View File

@@ -54,21 +54,40 @@ def test_credential_migration_team_member(credential, team, user, permissions):
rbac.migrate_credential(apps, None)
# Admin permissions post migration
# User permissions post migration
assert u in credential.use_role
assert u not in credential.admin_role
@pytest.mark.django_db
def test_credential_migration_team_admin(credential, team, user, permissions):
u = user('user', False)
team.member_role.members.add(u)
team.admin_role.members.add(u)
credential.deprecated_team = team
credential.save()
assert u not in credential.use_role
# Usage permissions post migration
# Admin permissions post migration
rbac.migrate_credential(apps, None)
assert u in credential.use_role
assert u in credential.admin_role
@pytest.mark.django_db
def test_credential_migration_org_auditor(credential, team, org_auditor):
# Team's organization is the org_auditor's org
credential.deprecated_team = team
credential.save()
# No permissions pre-migration (this happens automatically so we patch this)
team.admin_role.children.remove(credential.admin_role)
team.member_role.children.remove(credential.use_role)
assert org_auditor not in credential.read_role
rbac.migrate_credential(apps, None)
rbac.infer_credential_org_from_team(apps, None)
# Read permissions post migration
assert org_auditor not in credential.use_role
assert org_auditor in credential.read_role
def test_credential_access_superuser():
u = User(username='admin', is_superuser=True)
@@ -133,29 +152,6 @@ def test_org_credential_access_member(alice, org_credential, credential):
'description': 'New description.',
'organization': None})
@pytest.mark.django_db
def test_credential_access_org_permissions(
org_admin, org_member, organization, org_credential, credential):
credential.admin_role.members.add(org_admin)
credential.admin_role.members.add(org_member)
org_credential.admin_role.members.add(org_member)
access = CredentialAccess(org_admin)
member_access = CredentialAccess(org_member)
# Org admin can move their own credential into their org
assert access.can_change(credential, {'organization': organization.pk})
# Org member can not
assert not member_access.can_change(credential, {
'organization': organization.pk})
# Org admin can remove a credential from their org
assert access.can_change(org_credential, {'organization': None})
# Org member can not
assert not member_access.can_change(org_credential, {'organization': None})
assert not member_access.can_change(org_credential, {
'user': org_member.pk, 'organization': None})
@pytest.mark.django_db
def test_cred_job_template_xfail(user, deploy_jobtemplate):
' Personal credential migration '
@@ -248,7 +244,6 @@ def test_single_cred_multi_job_template_multi_org(user, organizations, credentia
orgs[0].admin_role.members.add(a)
orgs[1].admin_role.members.add(a)
access = CredentialAccess(a)
rbac.migrate_credential(apps, None)
for jt in jts:
@@ -256,11 +251,6 @@ def test_single_cred_multi_job_template_multi_org(user, organizations, credentia
credential.refresh_from_db()
assert jts[0].credential != jts[1].credential
assert access.can_change(jts[0].credential, {'organization': org.pk})
assert access.can_change(jts[1].credential, {'organization': org.pk})
orgs[0].admin_role.members.remove(a)
assert not access.can_change(jts[0].credential, {'organization': org.pk})
@pytest.mark.django_db
def test_cred_inventory_source(user, inventory, credential):

View File

@@ -92,6 +92,12 @@ def test_null_related_delete_denied(normal_job, rando):
access = JobAccess(rando)
assert not access.can_delete(normal_job)
@pytest.mark.django_db
def test_delete_job_with_orphan_proj(normal_job, rando):
normal_job.project.organization = None
access = JobAccess(rando)
assert not access.can_delete(normal_job)
@pytest.mark.django_db
def test_inventory_org_admin_delete_allowed(normal_job, org_admin):
normal_job.project = None # do this so we test job->inventory->org->admin connection

View File

@@ -192,8 +192,12 @@ class UsersTest(BaseTest):
self.post(url, expect=403, data=new_user, auth=self.get_other_credentials())
self.post(url, expect=201, data=new_user, auth=self.get_super_credentials())
self.post(url, expect=400, data=new_user, auth=self.get_super_credentials())
self.post(url, expect=201, data=new_user2, auth=self.get_normal_credentials())
self.post(url, expect=400, data=new_user2, auth=self.get_normal_credentials())
# org admin cannot create orphaned users
self.post(url, expect=403, data=new_user2, auth=self.get_normal_credentials())
# org admin can create org users
org_url = reverse('api:organization_users_list', args=(self.organizations[0].pk,))
self.post(org_url, expect=201, data=new_user2, auth=self.get_normal_credentials())
self.post(org_url, expect=400, data=new_user2, auth=self.get_normal_credentials())
# Normal user cannot add users after his org is marked inactive.
self.organizations[0].delete()
new_user3 = dict(username='blippy3')
@@ -325,9 +329,9 @@ class UsersTest(BaseTest):
detail_url = reverse('api:user_detail', args=(self.other_django_user.pk,))
data = self.get(detail_url, expect=200, auth=self.get_other_credentials())
# can't change first_name, last_name, etc
# can change first_name, last_name, etc
data['last_name'] = "NewLastName"
self.put(detail_url, data, expect=403, auth=self.get_other_credentials())
self.put(detail_url, data, expect=200, auth=self.get_other_credentials())
# can't change username
data['username'] = 'newUsername'
@@ -367,23 +371,20 @@ class UsersTest(BaseTest):
url = reverse('api:user_list')
data = dict(username='username', password='password')
data2 = dict(username='username2', password='password2')
data = self.post(url, expect=201, data=data, auth=self.get_normal_credentials())
# but a regular user cannot create users
self.post(url, expect=403, data=data2, auth=self.get_other_credentials())
# org admins cannot create orphaned users
self.post(url, expect=403, data=data2, auth=self.get_normal_credentials())
# a super user can create new users
self.post(url, expect=201, data=data, auth=self.get_super_credentials())
# verify that the login works...
self.get(url, expect=200, auth=('username', 'password'))
# but a regular user cannot
data = self.post(url, expect=403, data=data2, auth=self.get_other_credentials())
# a super user can also create new users
data = self.post(url, expect=201, data=data2, auth=self.get_super_credentials())
# verify that the login works
self.get(url, expect=200, auth=('username2', 'password2'))
# verify that if you post a user with a pk, you do not alter that user's password info
mod = dict(id=self.super_django_user.pk, username='change', password='change')
data = self.post(url, expect=201, data=mod, auth=self.get_super_credentials())
self.post(url, expect=201, data=mod, auth=self.get_super_credentials())
orig = User.objects.get(pk=self.super_django_user.pk)
self.assertTrue(orig.username != 'change')

View File

@@ -0,0 +1,102 @@
import mock
from mock import PropertyMock
import pytest
from rest_framework.test import APIRequestFactory
from rest_framework.test import force_authenticate
from django.contrib.contenttypes.models import ContentType
from awx.api.views import (
RoleUsersList,
UserRolesList,
TeamRolesList,
)
from awx.main.models import (
User,
Role,
)
@pytest.mark.skip(reason="Seeing pk error, suspect weirdness in mocking requests")
@pytest.mark.parametrize("pk, err", [
(111, "not change the membership"),
(1, "may not perform"),
])
def test_user_roles_list_user_admin_role(pk, err):
with mock.patch('awx.api.views.get_object_or_400') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role, id=1, pk=1)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
with mock.patch('awx.api.views.User.admin_role', new_callable=PropertyMock, return_value=role_mock):
factory = APIRequestFactory()
view = UserRolesList.as_view()
user = User(username="root", is_superuser=True)
request = factory.post("/user/1/roles", {'id':pk}, format="json")
force_authenticate(request, user)
response = view(request)
response.render()
assert response.status_code == 403
assert err in response.content
@pytest.mark.skip(reason="db access or mocking needed for new tests in role assignment code")
@pytest.mark.parametrize("admin_role, err", [
(True, "may not perform"),
(False, "not change the membership"),
])
def test_role_users_list_other_user_admin_role(admin_role, err):
with mock.patch('awx.api.views.RoleUsersList.get_parent_object') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role, id=1)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
user_admin_role = role_mock if admin_role else None
with mock.patch('awx.api.views.User.admin_role', new_callable=PropertyMock, return_value=user_admin_role):
factory = APIRequestFactory()
view = RoleUsersList.as_view()
user = User(username="root", is_superuser=True, pk=1, id=1)
request = factory.post("/role/1/users", {'id':1}, format="json")
force_authenticate(request, user)
response = view(request)
response.render()
assert response.status_code == 403
assert err in response.content
def test_team_roles_list_post_org_roles():
with mock.patch('awx.api.views.get_object_or_400') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
factory = APIRequestFactory()
view = TeamRolesList.as_view()
request = factory.post("/team/1/roles", {'id':1}, format="json")
force_authenticate(request, User(username="root", is_superuser=True))
response = view(request)
response.render()
assert response.status_code == 400
assert 'cannot assign' in response.content

View File

@@ -1,22 +1,11 @@
import mock
import pytest
from rest_framework.test import APIRequestFactory
from rest_framework.test import force_authenticate
from django.contrib.contenttypes.models import ContentType
from awx.api.views import (
ApiV1RootView,
TeamRolesList,
JobTemplateLabelList,
)
from awx.main.models import (
User,
Role,
)
@pytest.fixture
def mock_response_new(mocker):
m = mocker.patch('awx.api.views.Response.__new__')
@@ -68,30 +57,6 @@ class TestJobTemplateLabelList:
with mock.patch('awx.api.generics.DeleteLastUnattachLabelMixin.unattach') as mixin_unattach:
view = JobTemplateLabelList()
mock_request = mock.MagicMock()
super(JobTemplateLabelList, view).unattach(mock_request, None, None)
assert mixin_unattach.called_with(mock_request, None, None)
@pytest.mark.parametrize("url", ["/team/1/roles", "/role/1/teams"])
def test_team_roles_list_post_org_roles(url):
with mock.patch('awx.api.views.Role.objects.get') as role_get, \
mock.patch('awx.api.views.ContentType.objects.get_for_model') as ct_get:
role_mock = mock.MagicMock(spec=Role)
content_type_mock = mock.MagicMock(spec=ContentType)
role_mock.content_type = content_type_mock
role_get.return_value = role_mock
ct_get.return_value = content_type_mock
factory = APIRequestFactory()
view = TeamRolesList.as_view()
request = factory.post(url, {'id':1}, format="json")
force_authenticate(request, User(username="root", is_superuser=True))
response = view(request)
response.render()
assert response.status_code == 400
assert 'cannot assign' in response.content

View File

@@ -1,4 +1,5 @@
import pytest
import json
def test_missing_project_error(job_template_factory):
@@ -34,7 +35,18 @@ def test_inventory_credential_contradictions(job_template_factory):
assert 'inventory' in validation_errors
assert 'credential' in validation_errors
def test_survey_answers_as_string(job_template_factory):
objects = job_template_factory(
'job-template-with-survey',
survey=['var1'],
persisted=False)
jt = objects.job_template
user_extra_vars = json.dumps({'var1': 'asdf'})
accepted, ignored = jt._accept_or_ignore_job_kwargs(extra_vars=user_extra_vars)
assert 'var1' in accepted['extra_vars']
@pytest.mark.survey
def test_survey_password_list(job_with_secret_key_unit):
"""Verify that survey_password_variables method gives a list of survey passwords"""
assert job_with_secret_key_unit.job_template.survey_password_variables() == ['secret_key', 'SSN']
def test_job_template_survey_password_redaction(job_template_with_survey_passwords_unit):
"""Tests the JobTemplate model's funciton to redact passwords from
extra_vars - used when creating a new job"""
assert job_template_with_survey_passwords_unit.survey_password_variables() == ['secret_key', 'SSN']

View File

@@ -2,6 +2,7 @@ import pytest
import json
from awx.main.tasks import RunJob
from awx.main.models import Job
@pytest.fixture
@@ -14,9 +15,19 @@ def job(mocker):
'launch_type': 'manual'})
@pytest.mark.survey
def test_job_redacted_extra_vars(job_with_secret_key_unit):
"""Verify that this method redacts vars marked as passwords in a survey"""
assert json.loads(job_with_secret_key_unit.display_extra_vars()) == {
def test_job_survey_password_redaction():
"""Tests the Job model's funciton to redact passwords from
extra_vars - used when displaying job information"""
job = Job(
name="test-job-with-passwords",
extra_vars=json.dumps({
'submitter_email': 'foobar@redhat.com',
'secret_key': '6kQngg3h8lgiSTvIEb21',
'SSN': '123-45-6789'}),
survey_passwords={
'secret_key': '$encrypted$',
'SSN': '$encrypted$'})
assert json.loads(job.display_extra_vars()) == {
'submitter_email': 'foobar@redhat.com',
'secret_key': '$encrypted$',
'SSN': '$encrypted$'}

View File

@@ -41,8 +41,8 @@ def test_net_cred_ssh_agent(mocker, options):
'credential': None, 'cloud_credential': None, 'network_credential': Credential(**options),
'become_enabled': False, 'become_method': None, 'become_username': None,
'inventory': mocker.MagicMock(spec=Inventory, id=2), 'force_handlers': False,
'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': False,
'start_at_task': False, 'pk': 1, 'launch_type': 'normal', 'job_template':None,
'limit': None, 'verbosity': None, 'job_tags': None, 'skip_tags': None,
'start_at_task': None, 'pk': 1, 'launch_type': 'normal', 'job_template':None,
'created_by': None, 'extra_vars_dict': None, 'project':None, 'playbook': 'test.yml'}
mock_job = mocker.MagicMock(spec=Job, **mock_job_attrs)

View File

@@ -1,126 +1,462 @@
#!/usr/bin/python
# vim: set fileencoding=utf-8 :
#
# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org>
#
# This script is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with it. If not, see <http://www.gnu.org/licenses/>.
#
# This is loosely based on the foreman inventory script
# -- Josh Preston <jpreston@redhat.com>
#
'''
CloudForms external inventory script
==================================================
Generates inventory that Ansible can understand by making API request to CloudForms.
Modeled after https://raw.githubusercontent.com/ansible/ansible/stable-1.9/plugins/inventory/ec2.py
jlabocki <at> redhat.com or @jameslabocki on twitter
'''
import os
from __future__ import print_function
import argparse
import ConfigParser
import os
import re
from time import time
import requests
import json
from requests.auth import HTTPBasicAuth
import warnings
try:
import json
except ImportError:
import simplejson as json
# This disables warnings and is not a good idea, but hey, this is a demo
# http://urllib3.readthedocs.org/en/latest/security.html#disabling-warnings
requests.packages.urllib3.disable_warnings()
class CloudFormsInventory(object):
def _empty_inventory(self):
return {"_meta" : {"hostvars" : {}}}
def __init__(self):
''' Main execution path '''
"""
Main execution path
"""
self.inventory = dict() # A list of groups and the hosts in that group
self.hosts = dict() # Details about hosts in the inventory
# Inventory grouped by instance IDs, tags, security groups, regions,
# and availability zones
self.inventory = self._empty_inventory()
# Index of hostname (address) to instance ID
self.index = {}
# Read CLI arguments
self.read_settings()
# Parse CLI arguments
self.parse_cli_args()
# Get Hosts
if self.args.list:
self.get_hosts()
# Read settings
self.read_settings()
# This doesn't exist yet and needs to be added
# Cache
if self.args.refresh_cache or not self.is_cache_valid():
self.update_cache()
else:
self.load_inventory_from_cache()
self.load_hosts_from_cache()
data_to_print = ""
# Data to print
if self.args.host:
data2 = { }
print json.dumps(data2, indent=2)
if self.args.debug:
print("Fetching host [%s]" % self.args.host)
data_to_print += self.get_host_info(self.args.host)
else:
self.inventory['_meta'] = {'hostvars': {}}
for hostname in self.hosts:
self.inventory['_meta']['hostvars'][hostname] = {
'cloudforms': self.hosts[hostname],
}
# include the ansible_ssh_host in the top level
if 'ansible_ssh_host' in self.hosts[hostname]:
self.inventory['_meta']['hostvars'][hostname]['ansible_ssh_host'] = self.hosts[hostname]['ansible_ssh_host']
def parse_cli_args(self):
''' Command line argument processing '''
data_to_print += self.json_format_dict(self.inventory, self.args.pretty)
parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on CloudForms')
parser.add_argument('--list', action='store_true', default=False,
help='List instances (default: False)')
parser.add_argument('--host', action='store',
help='Get all the variables about a specific instance')
self.args = parser.parse_args()
print(data_to_print)
def is_cache_valid(self):
"""
Determines if the cache files have expired, or if it is still valid
"""
if self.args.debug:
print("Determining if cache [%s] is still valid (< %s seconds old)" % (self.cache_path_hosts, self.cache_max_age))
if os.path.isfile(self.cache_path_hosts):
mod_time = os.path.getmtime(self.cache_path_hosts)
current_time = time()
if (mod_time + self.cache_max_age) > current_time:
if os.path.isfile(self.cache_path_inventory):
if self.args.debug:
print("Cache is still valid!")
return True
if self.args.debug:
print("Cache is stale or does not exist.")
return False
def read_settings(self):
''' Reads the settings from the cloudforms.ini file '''
"""
Reads the settings from the cloudforms.ini file
"""
config = ConfigParser.SafeConfigParser()
config_paths = [
os.path.join(os.path.dirname(os.path.realpath(__file__)), 'cloudforms.ini'),
"/opt/rh/cloudforms.ini",
os.path.dirname(os.path.realpath(__file__)) + '/cloudforms.ini',
"/etc/ansible/cloudforms.ini",
]
env_value = os.environ.get('CLOUDFORMS_INI_PATH')
if env_value is not None:
config_paths.append(os.path.expanduser(os.path.expandvars(env_value)))
if self.args.debug:
for config_path in config_paths:
print("Reading from configuration file [%s]" % config_path)
config.read(config_paths)
# Version
if config.has_option('cloudforms', 'version'):
self.cloudforms_version = config.get('cloudforms', 'version')
# CloudForms API related
if config.has_option('cloudforms', 'url'):
self.cloudforms_url = config.get('cloudforms', 'url')
else:
self.cloudforms_version = "none"
self.cloudforms_url = None
# CloudForms Endpoint
if config.has_option('cloudforms', 'hostname'):
self.cloudforms_hostname = config.get('cloudforms', 'hostname')
else:
self.cloudforms_hostname = None
if not self.cloudforms_url:
warnings.warn("No url specified, expected something like 'https://cfme.example.com'")
# CloudForms Username
if config.has_option('cloudforms', 'username'):
self.cloudforms_username = config.get('cloudforms', 'username')
else:
self.cloudforms_username = "none"
self.cloudforms_username = None
if not self.cloudforms_username:
warnings.warn("No username specified, you need to specify a CloudForms username.")
# CloudForms Password
if config.has_option('cloudforms', 'password'):
self.cloudforms_password = config.get('cloudforms', 'password')
self.cloudforms_pw = config.get('cloudforms', 'password')
else:
self.cloudforms_password = "none"
self.cloudforms_pw = None
def get_hosts(self):
''' Gets host from CloudForms '''
r = requests.get("https://" + self.cloudforms_hostname + "/api/vms?expand=resources&attributes=name,power_state", auth=(self.cloudforms_username,self.cloudforms_password), verify=False)
if not self.cloudforms_pw:
warnings.warn("No password specified, you need to specify a password for the CloudForms user.")
obj = r.json()
if config.has_option('cloudforms', 'ssl_verify'):
self.cloudforms_ssl_verify = config.getboolean('cloudforms', 'ssl_verify')
else:
self.cloudforms_ssl_verify = True
#Remove objects that don't matter
del obj["count"]
del obj["subcount"]
del obj["name"]
if config.has_option('cloudforms', 'version'):
self.cloudforms_version = config.get('cloudforms', 'version')
else:
self.cloudforms_version = None
#Create a new list to grab VMs with power_state on to add to a new list
#I'm sure there is a cleaner way to do this
newlist = []
getnext = False
for x in obj.items():
for y in x[1]:
for z in y.items():
if getnext == True:
newlist.append(z[1])
getnext = False
if ( z[0] == "power_state" and z[1] == "on" ):
getnext = True
newdict = {'hosts': newlist}
newdict2 = {'Dynamic_CloudForms': newdict}
print json.dumps(newdict2, indent=2)
if config.has_option('cloudforms', 'limit'):
self.cloudforms_limit = config.getint('cloudforms', 'limit')
else:
self.cloudforms_limit = 100
if config.has_option('cloudforms', 'purge_actions'):
self.cloudforms_purge_actions = config.getboolean('cloudforms', 'purge_actions')
else:
self.cloudforms_purge_actions = True
if config.has_option('cloudforms', 'clean_group_keys'):
self.cloudforms_clean_group_keys = config.getboolean('cloudforms', 'clean_group_keys')
else:
self.cloudforms_clean_group_keys = True
if config.has_option('cloudforms', 'nest_tags'):
self.cloudforms_nest_tags = config.getboolean('cloudforms', 'nest_tags')
else:
self.cloudforms_nest_tags = False
# Ansible related
try:
group_patterns = config.get('ansible', 'group_patterns')
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
group_patterns = "[]"
self.group_patterns = eval(group_patterns)
# Cache related
try:
cache_path = os.path.expanduser(config.get('cache', 'path'))
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
cache_path = '.'
(script, ext) = os.path.splitext(os.path.basename(__file__))
self.cache_path_hosts = cache_path + "/%s.hosts" % script
self.cache_path_inventory = cache_path + "/%s.inventory" % script
self.cache_max_age = config.getint('cache', 'max_age')
if self.args.debug:
print("CloudForms settings:")
print("cloudforms_url = %s" % self.cloudforms_url)
print("cloudforms_username = %s" % self.cloudforms_username)
print("cloudforms_pw = %s" % self.cloudforms_pw)
print("cloudforms_ssl_verify = %s" % self.cloudforms_ssl_verify)
print("cloudforms_version = %s" % self.cloudforms_version)
print("cloudforms_limit = %s" % self.cloudforms_limit)
print("cloudforms_purge_actions = %s" % self.cloudforms_purge_actions)
print("Cache settings:")
print("cache_max_age = %s" % self.cache_max_age)
print("cache_path_hosts = %s" % self.cache_path_hosts)
print("cache_path_inventory = %s" % self.cache_path_inventory)
def parse_cli_args(self):
"""
Command line argument processing
"""
parser = argparse.ArgumentParser(description='Produce an Ansible Inventory file based on CloudForms managed VMs')
parser.add_argument('--list', action='store_true', default=True, help='List instances (default: True)')
parser.add_argument('--host', action='store', help='Get all the variables about a specific instance')
parser.add_argument('--pretty', action='store_true', default=False, help='Pretty print JSON output (default: False)')
parser.add_argument('--refresh-cache', action='store_true', default=False,
help='Force refresh of cache by making API requests to CloudForms (default: False - use cache files)')
parser.add_argument('--debug', action='store_true', default=False, help='Show debug output while running (default: False)')
self.args = parser.parse_args()
def _get_json(self, url):
"""
Make a request and return the JSON
"""
results = []
ret = requests.get(url,
auth=HTTPBasicAuth(self.cloudforms_username, self.cloudforms_pw),
verify=self.cloudforms_ssl_verify)
ret.raise_for_status()
try:
results = json.loads(ret.text)
except ValueError:
warnings.warn("Unexpected response from {0} ({1}): {2}".format(self.cloudforms_url, ret.status_code, ret.reason))
results = {}
if self.args.debug:
print("=======================================================================")
print("=======================================================================")
print("=======================================================================")
print(ret.text)
print("=======================================================================")
print("=======================================================================")
print("=======================================================================")
return results
def _get_hosts(self):
"""
Get all hosts by paging through the results
"""
limit = self.cloudforms_limit
page = 0
last_page = False
results = []
while not last_page:
offset = page * limit
ret = self._get_json("%s/api/vms?offset=%s&limit=%s&expand=resources,tags,hosts,&attributes=ipaddresses" % (self.cloudforms_url, offset, limit))
results += ret['resources']
if ret['subcount'] < limit:
last_page = True
page += 1
return results
def update_cache(self):
"""
Make calls to cloudforms and save the output in a cache
"""
self.groups = dict()
self.hosts = dict()
if self.args.debug:
print("Updating cache...")
for host in self._get_hosts():
# Ignore VMs that are not powered on
if host['power_state'] != 'on':
if self.args.debug:
print("Skipping %s because power_state = %s" % (host['name'], host['power_state']))
continue
# purge actions
if self.cloudforms_purge_actions and 'actions' in host:
del host['actions']
# Create ansible groups for tags
if 'tags' in host:
# Create top-level group
if 'tags' not in self.inventory:
self.inventory['tags'] = dict(children=[], vars={}, hosts=[])
if not self.cloudforms_nest_tags:
# don't expand tags, just use them in a safe way
for group in host['tags']:
# Add sub-group, as a child of top-level
safe_key = self.to_safe(group['name'])
if safe_key:
if self.args.debug:
print("Adding sub-group '%s' to parent 'tags'" % safe_key)
if safe_key not in self.inventory['tags']['children']:
self.push(self.inventory['tags'], 'children', safe_key)
self.push(self.inventory, safe_key, host['name'])
if self.args.debug:
print("Found tag [%s] for host which will be mapped to [%s]" % (group['name'], safe_key))
else:
# expand the tags into nested groups / sub-groups
# Create nested groups for tags
safe_parent_tag_name = 'tags'
for tag in host['tags']:
tag_hierarchy = tag['name'][1:].split('/')
if self.args.debug:
print("Working on list %s" % tag_hierarchy)
for tag_name in tag_hierarchy:
if self.args.debug:
print("Working on tag_name = %s" % tag_name)
safe_tag_name = self.to_safe(tag_name)
if self.args.debug:
print("Using sanitized name %s" % safe_tag_name)
# Create sub-group
if safe_tag_name not in self.inventory:
self.inventory[safe_tag_name] = dict(children=[], vars={}, hosts=[])
# Add sub-group, as a child of top-level
if safe_parent_tag_name:
if self.args.debug:
print("Adding sub-group '%s' to parent '%s'" % (safe_tag_name, safe_parent_tag_name))
if safe_tag_name not in self.inventory[safe_parent_tag_name]['children']:
self.push(self.inventory[safe_parent_tag_name], 'children', safe_tag_name)
# Make sure the next one uses this one as it's parent
safe_parent_tag_name = safe_tag_name
# Add the host to the last tag
self.push(self.inventory[safe_parent_tag_name], 'hosts', host['name'])
# Set ansible_ssh_host to the first available ip address
if 'ipaddresses' in host and host['ipaddresses'] and isinstance(host['ipaddresses'], list):
host['ansible_ssh_host'] = host['ipaddresses'][0]
# Create additional groups
for key in ('location', 'type', 'vendor'):
safe_key = self.to_safe(host[key])
# Create top-level group
if key not in self.inventory:
self.inventory[key] = dict(children=[], vars={}, hosts=[])
# Create sub-group
if safe_key not in self.inventory:
self.inventory[safe_key] = dict(children=[], vars={}, hosts=[])
# Add sub-group, as a child of top-level
if safe_key not in self.inventory[key]['children']:
self.push(self.inventory[key], 'children', safe_key)
if key in host:
# Add host to sub-group
self.push(self.inventory[safe_key], 'hosts', host['name'])
self.hosts[host['name']] = host
self.push(self.inventory, 'all', host['name'])
if self.args.debug:
print("Saving cached data")
self.write_to_cache(self.hosts, self.cache_path_hosts)
self.write_to_cache(self.inventory, self.cache_path_inventory)
def get_host_info(self, host):
"""
Get variables about a specific host
"""
if not self.hosts or len(self.hosts) == 0:
# Need to load cache from cache
self.load_hosts_from_cache()
if host not in self.hosts:
if self.args.debug:
print("[%s] not found in cache." % host)
# try updating the cache
self.update_cache()
if host not in self.hosts:
if self.args.debug:
print("[%s] does not exist after cache update." % host)
# host might not exist anymore
return self.json_format_dict({}, self.args.pretty)
return self.json_format_dict(self.hosts[host], self.args.pretty)
def push(self, d, k, v):
"""
Safely puts a new entry onto an array.
"""
if k in d:
d[k].append(v)
else:
d[k] = [v]
def load_inventory_from_cache(self):
"""
Reads the inventory from the cache file sets self.inventory
"""
cache = open(self.cache_path_inventory, 'r')
json_inventory = cache.read()
self.inventory = json.loads(json_inventory)
def load_hosts_from_cache(self):
"""
Reads the cache from the cache file sets self.hosts
"""
cache = open(self.cache_path_hosts, 'r')
json_cache = cache.read()
self.hosts = json.loads(json_cache)
def write_to_cache(self, data, filename):
"""
Writes data in JSON format to a file
"""
json_data = self.json_format_dict(data, True)
cache = open(filename, 'w')
cache.write(json_data)
cache.close()
def to_safe(self, word):
"""
Converts 'bad' characters in a string to underscores so they can be used as Ansible groups
"""
if self.cloudforms_clean_group_keys:
regex = "[^A-Za-z0-9\_]"
return re.sub(regex, "_", word.replace(" ", ""))
else:
return word
def json_format_dict(self, data, pretty=False):
"""
Converts a dict to a JSON object and dumps it as a formatted string
"""
if pretty:
return json.dumps(data, sort_keys=True, indent=2)
else:
return json.dumps(data)
# Run the script
CloudFormsInventory()

View File

@@ -1,8 +1,6 @@
#!/usr/bin/python
#!/usr/bin/env python
# vim: set fileencoding=utf-8 :
#
# NOTE FOR TOWER: change foreman_ to sattelite_ for the group prefix
#
# Copyright (C) 2016 Guido Günther <agx@sigxcpu.org>
#
# This script is free software: you can redistribute it and/or modify
@@ -41,6 +39,7 @@ class ForemanInventory(object):
self.inventory = dict() # A list of groups and the hosts in that group
self.cache = dict() # Details about hosts in the inventory
self.params = dict() # Params of each host
self.facts = dict() # Facts of each host
self.hostgroups = dict() # host groups
# Read settings and parse CLI arguments
@@ -55,6 +54,7 @@ class ForemanInventory(object):
else:
self.load_inventory_from_cache()
self.load_params_from_cache()
self.load_facts_from_cache()
self.load_cache_from_cache()
data_to_print = ""
@@ -69,6 +69,9 @@ class ForemanInventory(object):
'foreman': self.cache[hostname],
'foreman_params': self.params[hostname],
}
if self.want_facts:
self.inventory['_meta']['hostvars'][hostname]['foreman_facts'] = self.facts[hostname]
data_to_print += self.json_format_dict(self.inventory, True)
print(data_to_print)
@@ -81,7 +84,8 @@ class ForemanInventory(object):
current_time = time()
if (mod_time + self.cache_max_age) > current_time:
if (os.path.isfile(self.cache_path_inventory) and
os.path.isfile(self.cache_path_params)):
os.path.isfile(self.cache_path_params) and
os.path.isfile(self.cache_path_facts)):
return True
return False
@@ -114,6 +118,16 @@ class ForemanInventory(object):
self.group_patterns = eval(group_patterns)
try:
self.group_prefix = config.get('ansible', 'group_prefix')
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
self.group_prefix = "foreman_"
try:
self.want_facts = config.getboolean('ansible', 'want_facts')
except (ConfigParser.NoOptionError, ConfigParser.NoSectionError):
self.want_facts = True
# Cache related
try:
cache_path = os.path.expanduser(config.get('cache', 'path'))
@@ -123,6 +137,7 @@ class ForemanInventory(object):
self.cache_path_cache = cache_path + "/%s.cache" % script
self.cache_path_inventory = cache_path + "/%s.index" % script
self.cache_path_params = cache_path + "/%s.params" % script
self.cache_path_facts = cache_path + "/%s.facts" % script
self.cache_max_age = config.getint('cache', 'max_age')
def parse_cli_args(self):
@@ -135,7 +150,7 @@ class ForemanInventory(object):
help='Force refresh of cache by making API requests to foreman (default: False - use cache files)')
self.args = parser.parse_args()
def _get_json(self, url):
def _get_json(self, url, ignore_errors=None):
page = 1
results = []
while True:
@@ -143,10 +158,14 @@ class ForemanInventory(object):
auth=HTTPBasicAuth(self.foreman_user, self.foreman_pw),
verify=self.foreman_ssl_verify,
params={'page': page, 'per_page': 250})
if ignore_errors and ret.status_code in ignore_errors:
break
ret.raise_for_status()
json = ret.json()
if not json.has_key('results'):
return json
if type(json['results']) == type({}):
return json['results']
results = results + json['results']
if len(results) >= json['total']:
break
@@ -162,38 +181,44 @@ class ForemanInventory(object):
self.hostgroups[hid] = self._get_json(url)
return self.hostgroups[hid]
def _get_params_by_id(self, hid):
url = "%s/api/v2/hosts/%s/parameters" % (self.foreman_url, hid)
def _get_all_params_by_id(self, hid):
url = "%s/api/v2/hosts/%s" % (self.foreman_url, hid)
ret = self._get_json(url, [404])
if ret == []: ret = {}
return ret.get('all_parameters', {})
def _get_facts_by_id(self, hid):
url = "%s/api/v2/hosts/%s/facts" % (self.foreman_url, hid)
return self._get_json(url)
def _resolve_params(self, host):
"""
Resolve all host group params of the host using the top level
hostgroup and the ancestry.
Fetch host params and convert to dict
"""
hostgroup_id = host['hostgroup_id']
paramgroups = []
params = {}
if hostgroup_id:
hostgroup = self._get_hostgroup_by_id(hostgroup_id)
ancestry_path = hostgroup.get('ancestry', '')
ancestry = ancestry_path.split('/') if ancestry_path is not None else []
# Append top level hostgroup last to overwrite lower levels
# values
ancestry.append(hostgroup_id)
paramgroups = [self._get_hostgroup_by_id(hostgroup_id)['parameters']
for hostgroup_id in ancestry]
paramgroups += [self._get_params_by_id(host['id'])]
for paramgroup in paramgroups:
for param in paramgroup:
name = param['name']
params[name] = param['value']
for param in self._get_all_params_by_id(host['id']):
name = param['name']
params[name] = param['value']
return params
def _get_facts(self, host):
"""
Fetch all host facts of the host
"""
if not self.want_facts:
return {}
ret = self._get_facts_by_id(host['id'])
if len(ret.values()) == 0:
facts = {}
elif len(ret.values()) == 1:
facts = ret.values()[0]
else:
raise ValueError("More than one set of facts returned for '%s'" % host)
return facts
def update_cache(self):
"""Make calls to foreman and save the output in a cache"""
@@ -203,11 +228,17 @@ class ForemanInventory(object):
for host in self._get_hosts():
dns_name = host['name']
# Create ansible groups for hostgroup, location and organization
for group in ['hostgroup', 'location', 'organization']:
# Create ansible groups for hostgroup, environment, location and organization
for group in ['hostgroup', 'environment', 'location', 'organization']:
val = host.get('%s_name' % group)
if val:
safe_key = self.to_safe('satellite_%s_%s' % (group, val.lower()))
safe_key = self.to_safe('%s%s_%s' % (self.group_prefix, group, val.lower()))
self.push(self.inventory, safe_key, dns_name)
for group in ['lifecycle_environment', 'content_view']:
val = host.get('content_facet_attributes', {}).get('%s_name' % group)
if val:
safe_key = self.to_safe('%s%s_%s' % (self.group_prefix, group, val.lower()))
self.push(self.inventory, safe_key, dns_name)
params = self._resolve_params(host)
@@ -231,11 +262,13 @@ class ForemanInventory(object):
self.cache[dns_name] = host
self.params[dns_name] = params
self.facts[dns_name] = self._get_facts(host)
self.push(self.inventory, 'all', dns_name)
self.write_to_cache(self.cache, self.cache_path_cache)
self.write_to_cache(self.inventory, self.cache_path_inventory)
self.write_to_cache(self.params, self.cache_path_params)
self.write_to_cache(self.facts, self.cache_path_facts)
def get_host_info(self):
""" Get variables about a specific host """
@@ -274,6 +307,14 @@ class ForemanInventory(object):
json_params = cache.read()
self.params = json.loads(json_params)
def load_facts_from_cache(self):
""" Reads the index from the cache file sets self.index """
if not self.want_facts:
return
cache = open(self.cache_path_facts, 'r')
json_facts = cache.read()
self.facts = json.loads(json_facts)
def load_cache_from_cache(self):
""" Reads the cache from the cache file sets self.cache """
@@ -301,4 +342,7 @@ class ForemanInventory(object):
else:
return json.dumps(data)
ForemanInventory()
if __name__ == '__main__':
ForemanInventory()

View File

@@ -682,6 +682,16 @@ SATELLITE6_HOST_FILTER = r'^.+$'
SATELLITE6_EXCLUDE_EMPTY_GROUPS = True
SATELLITE6_INSTANCE_ID_VAR = 'foreman.id'
# ---------------------
# ----- CloudForms -----
# ---------------------
CLOUDFORMS_ENABLED_VAR = 'power_state'
CLOUDFORMS_ENABLED_VALUE = 'on'
CLOUDFORMS_GROUP_FILTER = r'^.+$'
CLOUDFORMS_HOST_FILTER = r'^.+$'
CLOUDFORMS_EXCLUDE_EMPTY_GROUPS = True
CLOUDFORMS_INSTANCE_ID_VAR = 'id'
# ---------------------
# -- Activity Stream --
# ---------------------

View File

@@ -1,2 +1,21 @@
# Copyright (c) 2015 Ansible, Inc.
# All Rights Reserved.
# Python
import threading
# Monkeypatch xmlsec.initialize() to only run once (https://github.com/ansible/ansible-tower/issues/3241).
xmlsec_init_lock = threading.Lock()
xmlsec_initialized = False
import dm.xmlsec.binding
original_xmlsec_initialize = dm.xmlsec.binding.initialize
def xmlsec_initialize(*args, **kwargs):
global xmlsec_init_lock, xmlsec_initialized, original_xmlsec_initialize
with xmlsec_init_lock:
if not xmlsec_initialized:
original_xmlsec_initialize(*args, **kwargs)
xmlsec_initialized = True
dm.xmlsec.binding.initialize = xmlsec_initialize

View File

@@ -80,12 +80,6 @@ a.red-txt:active {
text-overflow: ellipsis;
}
.name-column {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
blockquote {
font-size: 14px;
}

View File

@@ -44,9 +44,11 @@
color: @list-header-txt;
font-size: 14px;
font-weight: bold;
white-space: nowrap;
padding-bottom: 25px;
min-height: 45px;
word-break: break-all;
max-width: 90%;
word-wrap: break-word;
}
.Form-secondaryTitle{
@@ -55,7 +57,10 @@
min-height: 40px;
}
.Form-title--is_superuser, .Form-title--is_system_auditor, .Form-title--is_ldap_user{
.Form-title--is_superuser,
.Form-title--is_system_auditor,
.Form-title--is_ldap_user,
.Form-title--is_external_account{
height:15px;
color: @default-interface-txt;
background-color: @default-list-header-bg;
@@ -353,6 +358,11 @@
border-color: transparent transparent @field-dropdown-icon transparent!important;
}
.select2-container--default.select2-container--open.select2-container--below .select2-selection--single {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
.select2-dropdown{
border:1px solid @field-border;

View File

@@ -78,6 +78,7 @@ table, tbody {
padding-left: 15px;
padding-right: 15px;
border-top:0px!important;
word-wrap: break-word;
}
.List-tableCell.description-column {
@@ -383,6 +384,7 @@ table, tbody {
.List-action--showTooltipOnDisabled {
display: inline-block;
cursor: not-allowed;
}
.List-action--showTooltipOnDisabled .btn[disabled] {

View File

@@ -1,6 +1,6 @@
{
"name": "angular-codemirror",
"version": "1.0.2",
"version": "1.0.3",
"dependencies": {
"angular": "latest",
"angular-route": "latest",
@@ -13,14 +13,13 @@
"codemirror": "latest"
},
"homepage": "https://github.com/chouseknecht/angular-codemirror",
"_release": "1.0.2",
"_release": "1.0.3",
"_resolution": {
"type": "version",
"tag": "v1.0.2",
"commit": "94b7aac548b036f4fbd94e56129ed9574e472616"
"tag": "1.0.3",
"commit": "b94dc86fde8f60a50b324054806d29d742177d21"
},
"_source": "git://github.com/chouseknecht/angular-codemirror.git",
"_target": "~1.0.2",
"_originalSource": "angular-codemirror",
"_direct": true
"_source": "https://github.com/chouseknecht/angular-codemirror.git",
"_target": "~1.0.3",
"_originalSource": "angular-codemirror"
}

View File

@@ -1,6 +1,6 @@
{
"name": "angular-codemirror",
"version": "0.0.3",
"version": "1.0.2",
"dependencies": {
"angular": "latest",
"angular-route": "latest",

View File

@@ -1,6 +1,6 @@
/**********************************************
* AngularCodeMirror.css
*
*
* CodeMirror.css overrides
*
* Copyright (c) 2014 Chris Houseknecht
@@ -30,14 +30,14 @@
.CodeMirror {
height: auto;
}
.CodeMirror-activeline-background {
background-color: #f7f7f7;
}
/* Modal dialog overrides to make jqueryui dialog blend in with Twitter.
/* Modal dialog overrides to make jqueryui dialog blend in with Twitter.
Why? Twitter's modal is not draggable or resizable, which is not very
useful for a code editor */
@@ -71,7 +71,7 @@
border-color: #ffffff;
color: #A9A9A9;
}
.ui-dialog .ui-resizable-se {
right: 5px;
bottom: 5px;
@@ -108,3 +108,4 @@
.CodeMirror-lint-tooltip {
z-index: 2060;
}

View File

@@ -30,7 +30,7 @@
angular.module('AngularCodeMirrorModule', [])
.factory('AngularCodeMirror', [ function() {
return function() {
return function(readOnly) {
var fn = function() {
this.myCodeMirror = null;
@@ -43,7 +43,6 @@ angular.module('AngularCodeMirrorModule', [])
model = params.model,
mode = params.mode,
onReady = params.onReady,
onChange = params.onChange,
height = 0;
self.element = $(element);
@@ -69,6 +68,15 @@ angular.module('AngularCodeMirrorModule', [])
// Initialize CodeMirror
self.modes[mode].value = scope[model];
// if readOnly is passed to AngularCodeMirror, set the
// options for all modes to be readOnly
if (readOnly) {
Object.keys(self.modes).forEach(function(val) {
self.modes[val].readOnly = true;
});
}
self.myCodeMirror = CodeMirror(document.getElementById('cm-' + model + '-container'), self.modes[mode]);
// Adjust the height
@@ -85,14 +93,7 @@ angular.module('AngularCodeMirrorModule', [])
// Update the model on change
self.myCodeMirror.on('change', function() {
setTimeout(function() {
scope.$apply(function(){
scope[model] = self.myCodeMirror.getValue();
if (onChange) {
onChange();
}
});
}, 500);
setTimeout(function() { scope.$apply(function(){ scope[model] = self.myCodeMirror.getValue(); }); }, 500);
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -52,8 +52,7 @@
"tag": "2.0.0",
"commit": "8a1951c54a956c33964c99b338f3a4830e652689"
},
"_source": "git://github.com/tameraydin/ngToast.git",
"_source": "https://github.com/tameraydin/ngToast.git",
"_target": "~2.0.0",
"_originalSource": "ngtoast",
"_direct": true
"_originalSource": "ngtoast"
}

View File

@@ -51,23 +51,6 @@ export default
PaginateInit({ scope: scope,
list: list, url: url, pageSize: 5 });
if (scope.removePostRefresh) {
scope.removePostRefresh();
}
scope.removePostRefresh = scope.$on('PostRefresh', function () {
if(scope.allSelected && scope.allSelected.length > 0) {
// We need to check to see if any of the selected items are now in our list!
for(var i=0; i<scope.allSelected.length; i++) {
for(var j=0; j<scope[set].length; j++) {
if(scope.allSelected[i].id === scope[set][j].id && scope.allSelected[i].type === scope[set][j].type) {
// If so, let's go ahead and mark it as selected so that select-list-item knows to check the box
scope[set][j].isSelected = true;
}
}
}
}
});
scope.search(list.iterator);
});
}

View File

@@ -21,6 +21,13 @@
key: true,
label: 'name'
},
organization: {
label: 'organization',
ngBind: 'team.summary_fields.organization.name',
sourceModel: 'organization',
sourceField: 'name',
searchable: true
}
}
};

View File

@@ -66,6 +66,12 @@
display: inline-block;
color: @default-interface-txt;
text-transform: uppercase;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
vertical-align: bottom;
}
.BreadCrumb-item + .BreadCrumb-item:before {

View File

@@ -46,13 +46,13 @@ export function CredentialsList($scope, $rootScope, $location, $log,
Wait('stop');
$('#prompt-modal').modal('hide');
list.fields.kind.searchOptions = $scope.credential_kind_options;
list.fields.kind.searchOptions = $scope.credential_kind_options_list;
// Translate the kind value
for (i = 0; i < $scope.credentials.length; i++) {
for (j = 0; j < $scope.credential_kind_options.length; j++) {
if ($scope.credential_kind_options[j].value === $scope.credentials[i].kind) {
$scope.credentials[i].kind = $scope.credential_kind_options[j].label;
for (j = 0; j < $scope.credential_kind_options_list.length; j++) {
if ($scope.credential_kind_options_list[j].value === $scope.credentials[i].kind) {
$scope.credentials[i].kind = $scope.credential_kind_options_list[j].label;
break;
}
}
@@ -82,7 +82,7 @@ export function CredentialsList($scope, $rootScope, $location, $log,
scope: $scope,
url: defaultUrl,
field: 'kind',
variable: 'credential_kind_options',
variable: 'credential_kind_options_list',
callback: 'choicesReadyCredential'
});
@@ -148,7 +148,7 @@ export function CredentialsAdd($scope, $rootScope, $compile, $location, $log,
url;
$scope.keyEntered = false;
$scope.permissionsTooltip = 'Please save before assigning permissions';
generator.inject(form, { mode: 'add', related: false, scope: $scope });
generator.reset();
@@ -391,6 +391,12 @@ export function CredentialsEdit($scope, $rootScope, $compile, $location, $log,
$scope.removeCredentialLoaded();
}
$scope.removeCredentialLoaded = $scope.$on('credentialLoaded', function () {
// if the credential is assigned to an organization, allow permission delegation
// do NOT use $scope.organization in a view directive to determine if a credential is associated with an org
$scope.disablePermissionAssignment = typeof($scope.organization) === 'number' ? false : true;
if ($scope.disablePermissionAssignment){
$scope.permissionsTooltip = 'Credentials are only shared within an organization. Assign credentials to an organization to delegate credential permissions. The organization cannot be edited after credentials are assigned.';
}
var set;
for (set in relatedSets) {
$scope.search(relatedSets[set].iterator);

View File

@@ -20,7 +20,8 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa
var jobs_scope, scheduled_scope,
choicesCount = 0,
listCount = 0,
api_complete = false;
api_complete = false,
scheduledJobsList = _.cloneDeep(ScheduledJobsList);
$scope.jobsSelected = true;
@@ -66,22 +67,23 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa
scope: jobs_scope,
list: AllJobsList,
id: 'active-jobs',
pageSize: 20,
url: GetBasePath('unified_jobs') + '?status__in=pending,waiting,running,completed,failed,successful,error,canceled,new&order_by=-finished',
pageSize: 20,
searchParams: search_params,
spinner: false
});
scheduled_scope = $scope.$new(true);
scheduledJobsList.basePath = GetBasePath('schedules') + '?next_run__isnull=false';
LoadSchedulesScope({
parent_scope: $scope,
scope: scheduled_scope,
list: ScheduledJobsList,
list: scheduledJobsList,
pageSize: 20,
id: 'scheduled-jobs-tab',
searchSize: 'col-lg-4 col-md-4 col-sm-4 col-xs-12',
url: GetBasePath('schedules') + '?next_run__isnull=false'
url: scheduledJobsList.basePath
});
$scope.refreshJobs = function() {

View File

@@ -358,11 +358,8 @@ export function ProjectsList ($scope, $rootScope, $location, $log, $stateParams,
$scope.editSchedules = function(id) {
var project = Find({ list: $scope.projects, key: 'id', val: id });
if (project.scm_type === "Manual" || Empty(project.scm_type)) {
// Nothing to do
}
else {
$location.path('/projects/' + id + '/schedules');
if (!(project.scm_type === "Manual" || Empty(project.scm_type)) && !(project.status === 'updating' || project.status === 'running' || project.status === 'pending')) {
$state.go('projectSchedules', {id: id});
}
};
}

View File

@@ -162,6 +162,7 @@ export function UsersAdd($scope, $rootScope, $compile, $location, $log,
$scope.not_ldap_user = !$scope.ldap_user;
$scope.ldap_dn = null;
$scope.socialAuthUser = false;
$scope.external_account = null;
generator.reset();
@@ -334,6 +335,7 @@ export function UsersEdit($scope, $rootScope, $location,
$scope.not_ldap_user = !$scope.ldap_user;
master.ldap_user = $scope.ldap_user;
$scope.socialAuthUser = (data.auth.length > 0) ? true : false;
$scope.external_account = data.external_account;
$scope.user_type = $scope.user_type_options[0];
$scope.is_system_auditor = false;

View File

@@ -6,8 +6,8 @@
export default
['$scope', '$state', '$stateParams', 'PageRangeSetup', 'GetBasePath', 'DashboardHostsList',
'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope',
function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope){
'generateList', 'PaginateInit', 'SetStatus', 'DashboardHostService', 'hosts', '$rootScope', 'SearchInit',
function($scope, $state, $stateParams, PageRangeSetup, GetBasePath, DashboardHostsList, GenerateList, PaginateInit, SetStatus, DashboardHostService, hosts, $rootScope, SearchInit){
var setJobStatus = function(){
_.forEach($scope.hosts, function(value){
SetStatus({
@@ -59,6 +59,12 @@ export default
$scope.hosts = hosts.results;
setJobStatus();
generator.inject(list, {mode: 'edit', scope: $scope});
SearchInit({
scope: $scope,
set: 'hosts',
list: list,
url: defaultUrl
});
PaginateInit({
scope: $scope,
list: list,
@@ -77,6 +83,7 @@ export default
$scope.rowBeingEdited = $state.params.id;
$scope.listBeingEdited = "hosts";
}
$scope.search(list.iterator);
};
init();
}];

View File

@@ -31,6 +31,7 @@ export default function(){
awTipPlacement: 'right',
dataPlacement: 'right',
awPopOver: '{{ host.job_status_html }}',
dataTitle: '{{host.job_status_title}}',
ngClick:'viewHost(host.id)',
columnClass: 'col-lg-1 col-md-1 col-sm-2 col-xs-2 List-staticColumn--smallStatus'
},

View File

@@ -101,10 +101,8 @@
.DashboardList-nameCell {
padding-left: 15px;
text-overflow: ellipsis;
overflow:hidden;
white-space: nowrap;
width: 100%;
word-wrap: break-word;
}
.DashboardList-nameContainer {

View File

@@ -404,7 +404,9 @@ export default
related: {
permissions: {
awToolTip: 'Please save before assigning permissions',
disabled: 'disablePermissionAssignment',
awToolTip: '{{permissionsTooltip}}',
dataTipWatch: 'permissionsTooltip',
dataPlacement: 'top',
basePath: 'credentials/:id/access_list/',
type: 'collection',
@@ -452,7 +454,7 @@ export default
return {
permissions: {
iterator: 'permission',
url: urls.access_list
url: urls.access_list,
}
};
}

View File

@@ -81,7 +81,7 @@ export default
permissions: {
awToolTip: 'Please save before assigning permissions',
dataPlacement: 'top',
basePath: 'projects/:id/access_list/',
basePath: 'inventories/:id/access_list/',
type: 'collection',
title: 'Permissions',
iterator: 'permission',

View File

@@ -228,10 +228,7 @@ export default
column: 2,
awPopOver: "<p>Provide a comma separated list of tags.</p>\n" +
"<p>Tags are useful when you have a large playbook, and you want to run a specific part of a play or task.</p>" +
"<p>For example, you might have a task consisting of a long list of actions. Tag values can be assigned to each action. " +
"Suppose the actions have been assigned tag values of &quot;configuration&quot;, &quot;packages&quot; and &quot;install&quot;.</p>" +
"<p>If you just want to run the &quot;configuration&quot; and &quot;packages&quot; actions, you would enter the following here " +
"in the Job Tags field:</p>\n<blockquote>configuration,packages</blockquote>\n",
"<p>Consult the Ansible documentation for further details on the usage of tags.</p>",
dataTitle: "Job Tags",
dataPlacement: "right",
dataContainer: "body",
@@ -240,6 +237,25 @@ export default
text: 'Prompt on launch'
}
},
skip_tags: {
label: 'Skip Tags',
type: 'textarea',
rows: 5,
addRequired: false,
editRequired: false,
'elementClass': 'Form-textInput',
column: 2,
awPopOver: "<p>Provide a comma separated list of tags.</p>\n" +
"<p>Skip tags are useful when you have a large playbook, and you want to skip specific parts of a play or task.</p>" +
"<p>Consult the Ansible documentation for further details on the usage of tags.</p>",
dataTitle: "Skip Tags",
dataPlacement: "right",
dataContainer: "body",
subCheckbox: {
variable: 'ask_skip_tags_on_launch',
text: 'Prompt on launch'
}
},
checkbox_group: {
label: 'Options',
type: 'checkbox_group',
@@ -281,7 +297,7 @@ export default
column: 2,
awPopOver: "callback_help",
awPopOverWatch: "callback_help",
dataPlacement: 'right',
dataPlacement: 'top',
dataTitle: 'Provisioning Callback URL',
dataContainer: "body"
},

View File

@@ -46,7 +46,7 @@ export default
label: 'Username',
type: 'text',
awRequiredWhen: {
reqExpression: "not_ldap_user",
reqExpression: "not_ldap_user && external_account === null",
init: true
},
autocomplete: false
@@ -69,7 +69,7 @@ export default
label: 'Password',
type: 'sensitive',
hasShowInputButton: true,
ngShow: 'ldap_user == false && socialAuthUser === false',
ngShow: 'ldap_user == false && socialAuthUser === false && external_account === null',
addRequired: true,
editRequired: false,
ngChange: "clearPWConfirm('password_confirm')",
@@ -80,7 +80,7 @@ export default
label: 'Confirm Password',
type: 'sensitive',
hasShowInputButton: true,
ngShow: 'ldap_user == false && socialAuthUser === false',
ngShow: 'ldap_user == false && socialAuthUser === false && external_account === null',
addRequired: true,
editRequired: false,
awPassMatch: true,

View File

@@ -55,9 +55,9 @@ export default
// Submit request to run an adhoc comamand
.factory('AdhocRun', ['$location','$stateParams', 'LaunchJob',
'PromptForPasswords', 'Rest', 'GetBasePath', 'Alert', 'ProcessErrors',
'Wait', 'Empty', 'CreateLaunchDialog',
'Wait', 'Empty', 'CreateLaunchDialog', '$state',
function ($location, $stateParams, LaunchJob, PromptForPasswords,
Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog) {
Rest, GetBasePath, Alert, ProcessErrors, Wait, Empty, CreateLaunchDialog, $state) {
return function (params) {
var id = params.project_id,
scope = params.scope.$new(),
@@ -87,25 +87,31 @@ export default
});
});
if (scope.removeAdhocLaunchFinished) {
scope.removeAdhocLaunchFinished();
}
scope.removeAdhocLaunchFinished = scope.$on('AdhocLaunchFinished',
function(e, data) {
$location.path('/ad_hoc_commands/' + data.id);
});
if (scope.removeStartAdhocRun) {
scope.removeStartAdhocRun();
}
scope.removeStartAdhocRun = scope.$on('StartAdhocRun', function() {
LaunchJob({
scope: scope,
url: url,
callback: 'AdhocLaunchFinished' // send to the adhoc
// standard out page
});
var password,
postData={};
for (password in scope.passwords) {
postData[scope.passwords[password]] = scope[
scope.passwords[password]
];
}
// Re-launch the adhoc job
Rest.setUrl(url);
Rest.post(postData)
.success(function (data) {
Wait('stop');
$state.go('adHocJobStdout', {id: data.id});
})
.error(function (data, status) {
ProcessErrors(scope, data, status, {
hdr: 'Error!',
msg: 'Failed to launch adhoc command. POST ' +
'returned status: ' + status });
});
});
// start routine only if passwords need to be prompted

View File

@@ -161,7 +161,7 @@ angular.module('CredentialsHelper', ['Utilities'])
break;
case 'net':
scope.username_required = true;
scope.password_required = true;
scope.password_required = false;
scope.passwordLabel = 'Password';
scope.sshKeyDataLabel = 'SSH Key';
break;

View File

@@ -165,6 +165,7 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
has_inventory_sources = params.has_inventory_sources,
launch_class = '',
launch_tip = 'Start sync process',
schedule_tip = 'Schedule future inventory syncs',
stat, stat_class, status_tip;
stat = status;
@@ -225,7 +226,8 @@ angular.module('GroupsHelper', [ 'RestServices', 'Utilities', listGenerator.name
"tooltip": status_tip,
"status": stat,
"launch_class": launch_class,
"launch_tip": launch_tip
"launch_tip": launch_tip,
"schedule_tip": schedule_tip
};
};
}

View File

@@ -132,6 +132,9 @@ angular.module('JobTemplatesHelper', ['Utilities'])
scope.ask_tags_on_launch = (data.ask_tags_on_launch) ? true : false;
master.ask_tags_on_launch = scope.ask_tags_on_launch;
scope.ask_skip_tags_on_launch = (data.ask_skip_tags_on_launch) ? true : false;
master.ask_skip_tags_on_launch = scope.ask_skip_tags_on_launch;
scope.ask_job_type_on_launch = (data.ask_job_type_on_launch) ? true : false;
master.ask_job_type_on_launch = scope.ask_job_type_on_launch;

View File

@@ -25,7 +25,8 @@ export default
fld = (params.variable) ? params.variable : 'variables',
pfld = (params.parse_variable) ? params.parse_variable : 'parseType',
onReady = params.onReady,
onChange = params.onChange;
onChange = params.onChange,
readOnly = params.readOnly;
function removeField(fld) {
//set our model to the last change in CodeMirror and then destroy CodeMirror
@@ -35,8 +36,7 @@ export default
function createField(onChange, onReady, fld) {
//hide the textarea and show a fresh CodeMirror with the current mode (json or yaml)
scope[fld + 'codeMirror'] = AngularCodeMirror();
scope[fld + 'codeMirror'] = AngularCodeMirror(readOnly);
scope[fld + 'codeMirror'].addModes($AnsibleConfig.variable_edit_modes);
scope[fld + 'codeMirror'].showTextArea({
scope: scope,

View File

@@ -75,6 +75,9 @@ export default
case 'missing':
result = 'Missing. Click for details';
break;
case 'canceled':
result = 'Canceled. Click for details';
break;
}
return result;
};

View File

@@ -50,16 +50,9 @@ function InventoriesList($scope, $rootScope, $location, $log,
"aw-pop-over": html,
"data-popover-title": title,
"data-placement": "right" });
elem.removeAttr('ng-click');
$compile(elem)($scope);
elem.on('shown.bs.popover', function() {
$('.popover').each(function() {
$compile($(this))($scope); //make nested directives work!
});
$('.popover-content, .popover-title').click(function() {
elem.popover('hide');
});
});
elem.popover('show');
$scope.triggerPopover(event);
}
view.inject(InventoryList, { mode: mode, scope: $scope });
@@ -250,44 +243,62 @@ function InventoriesList($scope, $rootScope, $location, $log,
});
$scope.showGroupSummary = function(event, id) {
var inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.syncStatus !== 'na') {
Wait('start');
Rest.setUrl(inventory.related.inventory_sources + '?or__source=ec2&or__source=rax&order_by=-last_job_run&page_size=5');
Rest.get()
.success(function(data) {
$scope.$emit('GroupSummaryReady', event, inventory, data);
})
.error(function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + inventory.related.inventory_sources + ' failed. GET returned status: ' + status
try{
var elem = $(event.target).parent();
// if the popover is visible already, then exit the function here
if(elem.data()['bs.popover'].tip().hasClass('in')){
return;
}
}
catch(err){
var inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.syncStatus !== 'na') {
Wait('start');
Rest.setUrl(inventory.related.inventory_sources + '?or__source=ec2&or__source=rax&order_by=-last_job_run&page_size=5');
Rest.get()
.success(function(data) {
$scope.$emit('GroupSummaryReady', event, inventory, data);
})
.error(function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + inventory.related.inventory_sources + ' failed. GET returned status: ' + status
});
});
});
}
}
}
};
$scope.showHostSummary = function(event, id) {
var url, inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.total_hosts > 0) {
Wait('start');
url = GetBasePath('jobs') + "?type=job&inventory=" + id + "&failed=";
url += (inventory.has_active_failures) ? 'true' : "false";
url += "&order_by=-finished&page_size=5";
Rest.setUrl(url);
Rest.get()
.success( function(data) {
$scope.$emit('HostSummaryReady', event, data);
})
.error( function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. GET returned: ' + status
try{
var elem = $(event.target).parent();
// if the popover is visible already, then exit the function here
if(elem.data()['bs.popover'].tip().hasClass('in')){
return;
}
}
catch(err){
var url, inventory;
if (!Empty(id)) {
inventory = Find({ list: $scope.inventories, key: 'id', val: id });
if (inventory.total_hosts > 0) {
Wait('start');
url = GetBasePath('jobs') + "?type=job&inventory=" + id + "&failed=";
url += (inventory.has_active_failures) ? 'true' : "false";
url += "&order_by=-finished&page_size=5";
Rest.setUrl(url);
Rest.get()
.success( function(data) {
$scope.$emit('HostSummaryReady', event, data);
})
.error( function(data, status) {
ProcessErrors( $scope, data, status, null, { hdr: 'Error!',
msg: 'Call to ' + url + ' failed. GET returned: ' + status
});
});
});
}
}
}
};

View File

@@ -242,7 +242,7 @@ function adhocController($q, $scope, $location, $stateParams,
Rest.post(data)
.success(function (data) {
Wait('stop');
$location.path("/ad_hoc_commands/" + data.id);
$state.go('adHocJobStdout', {id: data.id});
})
.error(function (data, status) {
ProcessErrors($scope, data, status, adhocForm, {

View File

@@ -19,18 +19,4 @@
<li class="BreadCrumb-item" ng-if="currentState !== 'inventoryManage'"></li>
<div class="InventoryManageBreadCrumb-ncy" ng-if="!licenseMissing" ncy-breadcrumb></div>
</ol>
<div class="BreadCrumb-menuLink"
id="bread_crumb_activity_stream"
aw-tool-tip="View Activity Stream"
data-placement="left"
data-trigger="hover"
data-container="body"
ng-class="{'BreadCrumb-menuLinkActive' : activityStreamActive}"
ng-if="showActivityStreamButton"
ng-hide= "licenseMissing"
ng-click="openActivityStream()">
<i class="BreadCrumb-menuLinkImage icon-activity-stream"
alt="Activity Stream">
</i>
</div>
</div>

View File

@@ -100,6 +100,7 @@
// equal to case 'ec2' || 'rax' || 'azure' || 'azure_rm' || 'vmware' || 'satellite6' || 'cloudforms' || 'openstack'
else{
var credentialBasePath = (source === 'ec2') ? GetBasePath('credentials') + '?kind=aws' : GetBasePath('credentials') + (source === '' ? '' : '?kind=' + (source));
$scope.cloudCredentialRequired = source !== '' && source !== 'custom' && source !== 'ec2' ? true : false;
CredentialList.basePath = credentialBasePath;
LookUpInit({
scope: $scope,
@@ -122,7 +123,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 !== '' && source !== 'custom' ? true : false;
$scope.cloudCredentialRequired = source !== '' && source !== 'custom' && source !== 'ec2' ? true : false;
$scope.group_by = null;
$scope.source_regions = null;
$scope.credential = null;

View File

@@ -100,6 +100,7 @@
else{
var credentialBasePath = (source.value === 'ec2') ? GetBasePath('credentials') + '?kind=aws' : GetBasePath('credentials') + (source.value === '' ? '' : '?kind=' + (source.value));
CredentialList.basePath = credentialBasePath;
$scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' && source.value !== 'ec2' ? true : false;
LookUpInit({
scope: $scope,
url: credentialBasePath,
@@ -122,7 +123,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 !== '' && source.value !== 'custom' ? true : false;
$scope.cloudCredentialRequired = source.value !== '' && source.value !== 'custom' && source.value !== 'ec2' ? true : false;
$scope.group_by = null;
$scope.source_regions = null;
$scope.credential = null;

View File

@@ -151,6 +151,7 @@
{status_tooltip: group_status.tooltip},
{launch_tooltip: group_status.launch_tip},
{launch_class: group_status.launch_class},
{group_schedule_tooltip: group_status.schedule_tip},
{hosts_status_tip: hosts_status.tooltip},
{hosts_status_class: hosts_status.class},
{source: group.summary_fields.inventory_source ? group.summary_fields.inventory_source.source : null},

View File

@@ -13,11 +13,6 @@ import GroupsListController from './groups/groups-list.controller';
export default {
name: 'inventoryManage',
url: '/inventories/:inventory_id/manage?{group:int}{failed}',
data: {
activityStream: true,
activityStreamTarget: 'inventory',
activityStreamId: 'inventory_id'
},
params:{
group:{
array: true

View File

@@ -38,11 +38,13 @@
break;
case 'ok':
params.event = 'runner_on_ok';
params.changed = 'false';
break;
case 'failed':
params.failed = true;
params.event = 'runner_on_failed';
break;
case 'changed':
params.event = 'runner_on_ok';
params.changed = true;
break;
default:

View File

@@ -641,7 +641,7 @@ export default
return true;
});
//scope.setSearchAll('host');
ParseTypeChange({ scope: scope, field_id: 'pre-formatted-variables' });
ParseTypeChange({ scope: scope, field_id: 'pre-formatted-variables', readOnly: true });
scope.$emit('LoadPlays', data.related.job_events);
})
.error(function(data, status) {

View File

@@ -154,6 +154,11 @@
<div class="JobDetail-resultRowText">{{ job.job_tags }}</div>
</div>
<div class="form-group JobDetail-resultRow toggle-show" ng-show="job.skip_tags">
<label class="JobDetail-resultRowLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Skip Tags</label>
<div class="JobDetail-resultRowText">{{ job.skip_tags }}</div>
</div>
<div class="form-group JobDetail-resultRow JobDetail-resultRow--variables toggle-show" ng-show="variables">
<label class="JobDetail-resultRowLabel JobDetail-extraVarsLabel col-lg-2 col-md-2 col-sm-2 col-xs-3 control-label">Extra Variables</label>
<textarea rows="6" ng-model="variables" name="variables" class="JobDetail-extraVars" id="pre-formatted-variables"></textarea>

View File

@@ -42,10 +42,14 @@ export default
}
if(scope.ask_tags_on_launch && scope.other_prompt_data && scope.other_prompt_data.job_tags){
if(scope.ask_tags_on_launch && scope.other_prompt_data && typeof scope.other_prompt_data.job_tags === 'string'){
job_launch_data.job_tags = scope.other_prompt_data.job_tags;
}
if(scope.ask_skip_tags_on_launch && scope.other_prompt_data && typeof scope.other_prompt_data.skip_tags === 'string'){
job_launch_data.skip_tags = scope.other_prompt_data.skip_tags;
}
if(scope.ask_limit_on_launch && scope.other_prompt_data && scope.other_prompt_data.limit){
job_launch_data.limit = scope.other_prompt_data.limit;
}

View File

@@ -153,7 +153,7 @@ export default
// General catch-all for "other prompts" - used in this link function and to hide the Other Prompts tab when
// it should be hidden
$scope.has_other_prompts = (data.ask_job_type_on_launch || data.ask_limit_on_launch || data.ask_tags_on_launch || data.ask_variables_on_launch) ? true : false;
$scope.has_other_prompts = (data.ask_job_type_on_launch || data.ask_limit_on_launch || data.ask_tags_on_launch || data.ask_skip_tags_on_launch || data.ask_variables_on_launch) ? true : false;
$scope.password_needed = data.passwords_needed_to_start && data.passwords_needed_to_start.length > 0;
$scope.has_default_inventory = data.defaults && data.defaults.inventory && data.defaults.inventory.id;
$scope.has_default_credential = data.defaults && data.defaults.credential && data.defaults.credential.id;
@@ -172,6 +172,10 @@ export default
$scope.other_prompt_data.job_tags = (data.defaults && data.defaults.job_tags) ? data.defaults.job_tags : "";
}
if($scope.ask_skip_tags_on_launch) {
$scope.other_prompt_data.skip_tags = (data.defaults && data.defaults.skip_tags) ? data.defaults.skip_tags : "";
}
if($scope.ask_variables_on_launch) {
$scope.jobLaunchVariables = (data.defaults && data.defaults.extra_vars) ? data.defaults.extra_vars : "---";
$scope.other_prompt_data.parseType = 'yaml';

View File

@@ -148,7 +148,15 @@
<span class="Form-inputLabel">Job Tags</span>
</label>
<div>
<textarea rows="1" ng-model="other_prompt_data.job_tags" name="tags" class="form-control Form-textArea Form-textInput"></textarea>
<textarea rows="5" ng-model="other_prompt_data.job_tags" name="tags" class="form-control Form-textArea Form-textInput"></textarea>
</div>
</div>
<div class="form-group Form-formGroup Form-formGroup--singleColumn" ng-if="ask_skip_tags_on_launch">
<label for="skip_tags">
<span class="Form-inputLabel">Skip Tags</span>
</label>
<div>
<textarea rows="5" ng-model="other_prompt_data.skip_tags" name="skip_tags" class="form-control Form-textArea Form-textInput"></textarea>
</div>
</div>
</form>

View File

@@ -518,6 +518,7 @@
}
data.ask_tags_on_launch = $scope.ask_tags_on_launch ? $scope.ask_tags_on_launch : false;
data.ask_skip_tags_on_launch = $scope.ask_skip_tags_on_launch ? $scope.ask_skip_tags_on_launch : false;
data.ask_limit_on_launch = $scope.ask_limit_on_launch ? $scope.ask_limit_on_launch : false;
data.ask_job_type_on_launch = $scope.ask_job_type_on_launch ? $scope.ask_job_type_on_launch : false;
data.ask_inventory_on_launch = $scope.ask_inventory_on_launch ? $scope.ask_inventory_on_launch : false;

View File

@@ -640,6 +640,7 @@ export default
}
data.ask_tags_on_launch = $scope.ask_tags_on_launch ? $scope.ask_tags_on_launch : false;
data.ask_skip_tags_on_launch = $scope.ask_skip_tags_on_launch ? $scope.ask_skip_tags_on_launch : false;
data.ask_limit_on_launch = $scope.ask_limit_on_launch ? $scope.ask_limit_on_launch : false;
data.ask_job_type_on_launch = $scope.ask_job_type_on_launch ? $scope.ask_job_type_on_launch : false;
data.ask_inventory_on_launch = $scope.ask_inventory_on_launch ? $scope.ask_inventory_on_launch : false;

View File

@@ -8,7 +8,7 @@
}
.LabelList-tagContainer,
.LabelList-seeMore {
.LabelList-seeMoreLess {
display: flex;
max-width: 100%;
}
@@ -27,7 +27,7 @@
overflow: hidden;
}
.LabelList-seeMore {
.LabelList-seeMoreLess {
color: @default-link;
margin: 4px 0px;
text-transform: uppercase;
@@ -37,7 +37,7 @@
font-size: 11px;
}
.LabelList-seeMore:hover {
.LabelList-seeMoreLess:hover {
color: @default-link-hov;
}

View File

@@ -47,6 +47,13 @@ export default
});
};
scope.seeLess = function() {
// Trim the labels array back down to 10 items
scope.labels = scope.labels.slice(0, 10);
// Re-set the seeMoreInteractive flag so that the "See More" will be displayed
scope.seeMoreInactive = true;
};
scope.deleteLabel = function(templateId, templateName, labelId, labelName) {
var action = function () {
$('#prompt-modal').modal('hide');
@@ -56,13 +63,13 @@ export default
Rest.setUrl(url);
Rest.post({"disassociate": true, "id": labelId})
.success(function () {
scope.search("job_template");
scope.search("job_template", scope.$parent.job_template_page);
Wait('stop');
})
.error(function (data, status) {
Wait('stop');
ProcessErrors(scope, data, status, null, { hdr: 'Error!',
msg: 'Could not disacssociate label from JT. Call to ' + url + ' failed. DELETE returned status: ' + status });
msg: 'Could not disassociate label from JT. Call to ' + url + ' failed. DELETE returned status: ' + status });
});
};
@@ -86,6 +93,7 @@ export default
scope.count = null;
}
});
}
};
}

View File

@@ -8,5 +8,7 @@
<span class="LabelList-name">{{ label.name }}</span>
</div>
</div>
<div class="LabelList-seeMore" ng-show="count > 10 && seeMoreInactive"
<div class="LabelList-seeMoreLess" ng-show="count > 10 && seeMoreInactive"
ng-click="seeMore()">View More</div>
<div class="LabelList-seeMoreLess" ng-show="count > 10 && !seeMoreInactive"
ng-click="seeLess()">View Less</div>

View File

@@ -314,7 +314,7 @@ export default
ngClick: 'submitQuestion($event)',
ngDisabled: true,
'class': 'btn btn-sm Form-saveButton',
label: '{{editQuestionIndex === null ? "ADD" : "UPDATE"}}'
label: '{{editQuestionIndex === null ? "+ ADD" : "UPDATE"}}'
}
}

View File

@@ -48,9 +48,7 @@ export default
columnClass: 'col-lg-2 col-md-3 col-sm-4 col-xs-6',
ngClick: "viewJobDetails(all_job)",
defaultSearchField: true,
awToolTip: "{{ all_job.name | sanitize }}",
dataTipWatch: 'all_job.name',
dataPlacement: 'top'
searchDefault: true,
},
type: {
label: 'Type',

View File

@@ -35,6 +35,7 @@ export default
ngClick:"viewJobDetails(completed_job)",
searchable: true,
searchType: 'select',
defaultSearchField: true,
nosort: true,
searchOptions: [
{ label: "Success", value: "successful" },
@@ -54,8 +55,8 @@ export default
name: {
label: 'Name',
columnClass: 'col-lg-4 col-md-4 col-sm-4 col-xs-6',
searchable: false,
ngClick: "viewJobDetails(completed_job)",
defaultSearchField: true,
awToolTip: "{{ completed_job.name | sanitize }}",
dataPlacement: 'top'
},
@@ -64,7 +65,7 @@ export default
ngBind: 'completed_job.type_label',
link: false,
columnClass: "col-lg-2 col-md-2 hidden-sm hidden-xs",
searchable: true,
searchable: false,
searchType: 'select',
searchOptions: [] // populated via GetChoices() in controller
},

View File

@@ -19,6 +19,7 @@ export default
hover: true,
'class': 'table-no-border',
multiSelect: true,
trackBy: 'group.id',
fields: {
sync_status: {

View File

@@ -20,6 +20,7 @@ export default
hasChildren: true,
'class': 'table-no-border',
multiSelect: true,
trackBy: 'host.id',
fields: {
active_failures: {

View File

@@ -22,7 +22,8 @@ export default
key: true,
label: 'Name',
columnClass: 'col-lg-5 col-md-5 col-sm-9 col-xs-8',
linkTo: '/#/job_templates/{{job_template.id}}'
linkTo: '/#/job_templates/{{job_template.id}}',
searchDefault: true
},
description: {
label: 'Description',

View File

@@ -35,7 +35,8 @@ export default
label: 'Name',
columnClass: 'col-lg-4 col-md-4 col-sm-4 col-xs-6 List-staticColumnAdjacent',
defaultSearchField: true,
linkTo: '/#/jobs/{{job.id}}'
linkTo: '/#/jobs/{{job.id}}',
searchDefault: true
},
finished: {
label: 'Finished',

View File

@@ -37,6 +37,7 @@ export default
},
name: {
key: true,
searchDefault: true,
label: 'Name',
columnClass: "col-lg-4 col-md-4 col-sm-5 col-xs-7 List-staticColumnAdjacent",
modalColumnClass: 'col-md-8'

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