diff --git a/Makefile b/Makefile index 2ff93989dd..bc628758a1 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ CLIENT_TEST_DIR ?= build_test # Python packages to install only from source (not from binary wheels) # Comma separated list -SRC_ONLY_PKGS ?= cffi +SRC_ONLY_PKGS ?= cffi,pycparser # Determine appropriate shasum command UNAME_S := $(shell uname -s) @@ -663,10 +663,10 @@ release_build: # Build setup tarball tar-build/$(SETUP_TAR_FILE): @mkdir -p tar-build - @cp -a setup tar-build/$(SETUP_TAR_NAME) + @rsync -az --exclude /test setup/ tar-build/$(SETUP_TAR_NAME) @rsync -az docs/licenses tar-build/$(SETUP_TAR_NAME)/ @cd tar-build/$(SETUP_TAR_NAME) && sed -e 's#%NAME%#$(NAME)#;s#%VERSION%#$(VERSION)#;s#%RELEASE%#$(RELEASE)#;' group_vars/all.in > group_vars/all - @cd tar-build && tar -czf $(SETUP_TAR_FILE) --exclude "*/all.in" --exclude "**/test/*" $(SETUP_TAR_NAME)/ + @cd tar-build && tar -czf $(SETUP_TAR_FILE) --exclude "*/all.in" $(SETUP_TAR_NAME)/ @ln -sf $(SETUP_TAR_FILE) tar-build/$(SETUP_TAR_LINK) tar-build/$(SETUP_TAR_CHECKSUM): @@ -703,7 +703,7 @@ setup-bundle-build: # TODO - Somehow share implementation with setup_tarball setup-bundle-build/$(OFFLINE_TAR_FILE): - cp -a setup setup-bundle-build/$(OFFLINE_TAR_NAME) + rsync -az --exclude /test setup/ setup-bundle-build/$(OFFLINE_TAR_NAME) rsync -az docs/licenses setup-bundle-build/$(OFFLINE_TAR_NAME)/ cd setup-bundle-build/$(OFFLINE_TAR_NAME) && sed -e 's#%NAME%#$(NAME)#;s#%VERSION%#$(VERSION)#;s#%RELEASE%#$(RELEASE)#;' group_vars/all.in > group_vars/all $(PYTHON) $(DEPS_SCRIPT) -d $(DIST) -r $(DIST_MAJOR) -u $(AW_REPO_URL) -s setup-bundle-build/$(OFFLINE_TAR_NAME) -v -v -v diff --git a/awx/__init__.py b/awx/__init__.py index bf3f75255c..fe3644cf53 100644 --- a/awx/__init__.py +++ b/awx/__init__.py @@ -5,7 +5,7 @@ import os import sys import warnings -__version__ = '3.0.2' +__version__ = '3.0.3' __all__ = ['__version__'] diff --git a/awx/api/filters.py b/awx/api/filters.py index 55155224c4..08a26735d2 100644 --- a/awx/api/filters.py +++ b/awx/api/filters.py @@ -14,7 +14,7 @@ from django.contrib.contenttypes.models import ContentType from django.utils.encoding import force_text # Django REST Framework -from rest_framework.exceptions import ParseError +from rest_framework.exceptions import ParseError, PermissionDenied from rest_framework.filters import BaseFilterBackend # Ansible Tower @@ -97,7 +97,10 @@ class FieldLookupBackend(BaseFilterBackend): new_parts.append(name) - if name == 'pk': + + if name in getattr(model, 'PASSWORD_FIELDS', ()): + raise PermissionDenied('Filtering on password fields is not allowed.') + elif name == 'pk': field = model._meta.pk else: field = model._meta.get_field_by_name(name)[0] diff --git a/awx/api/templates/api/job_template_label_list.md b/awx/api/templates/api/job_template_label_list.md index 76c520eab5..9d503e9c65 100644 --- a/awx/api/templates/api/job_template_label_list.md +++ b/awx/api/templates/api/job_template_label_list.md @@ -2,7 +2,7 @@ Labels not associated with any other resources are deleted. A label can become disassociated with a resource as a result of 3 events. -1. A label is explicitly diassociated with a related job template +1. A label is explicitly disassociated with a related job template 2. A job is deleted with labels 3. A cleanup job deletes a job with labels diff --git a/awx/main/access.py b/awx/main/access.py index 5fa3b76274..d122d48da8 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -661,7 +661,6 @@ class CredentialAccess(BaseAccess): 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): @@ -1095,17 +1094,32 @@ class JobAccess(BaseAccess): if self.user.is_superuser: return True - # If a user can launch the job template then they can relaunch a job from that - # job template + inventory_access = obj.inventory and self.user in obj.inventory.use_role + credential_access = obj.credential and self.user in obj.credential.use_role + + # Check if JT execute access (and related prompts) is sufficient if obj.job_template is not None: - return self.user in obj.job_template.execute_role + prompts_access = True + job_fields = {} + for fd in obj.job_template._ask_for_vars_dict(): + job_fields[fd] = getattr(obj, fd) + accepted_fields, ignored_fields = obj.job_template._accept_or_ignore_job_kwargs(**job_fields) + for fd in ignored_fields: + if fd != 'extra_vars' and job_fields[fd] != getattr(obj.job_template, fd): + # Job has field that is not promptable + prompts_access = False + if obj.credential != obj.job_template.credential and not credential_access: + prompts_access = False + if obj.inventory != obj.job_template.inventory and not inventory_access: + prompts_access = False + if prompts_access and self.user in obj.job_template.execute_role: + return True - inventory_access = self.user in obj.inventory.use_role - credential_access = self.user in obj.credential.use_role - org_access = self.user in obj.inventory.organization.admin_role + org_access = obj.inventory and self.user in obj.inventory.organization.admin_role project_access = obj.project is None or self.user in obj.project.admin_role + # job can be relaunched if user could make an equivalent JT return inventory_access and credential_access and (org_access or project_access) def can_cancel(self, obj): @@ -1123,8 +1137,10 @@ class SystemJobTemplateAccess(BaseAccess): model = SystemJobTemplate + @check_superuser def can_start(self, obj): - return self.can_read(obj) + '''Only a superuser can start a job from a SystemJobTemplate''' + return False class SystemJobAccess(BaseAccess): ''' diff --git a/awx/main/management/commands/run_callback_receiver.py b/awx/main/management/commands/run_callback_receiver.py index e6080fa419..c31b3dffe2 100644 --- a/awx/main/management/commands/run_callback_receiver.py +++ b/awx/main/management/commands/run_callback_receiver.py @@ -25,8 +25,6 @@ from awx.main.socket import Socket logger = logging.getLogger('awx.main.commands.run_callback_receiver') -WORKERS = 4 - class CallbackReceiver(object): def __init__(self): self.parent_mappings = {} @@ -54,7 +52,7 @@ class CallbackReceiver(object): if use_workers: connection.close() - for idx in range(WORKERS): + for idx in range(settings.JOB_EVENT_WORKERS): queue_actual = Queue(settings.JOB_EVENT_MAX_QUEUE_SIZE) w = Process(target=self.callback_worker, args=(queue_actual, idx,)) w.start() @@ -99,7 +97,7 @@ class CallbackReceiver(object): time.sleep(0.1) def write_queue_worker(self, preferred_queue, worker_queues, message): - queue_order = sorted(range(WORKERS), cmp=lambda x, y: -1 if x==preferred_queue else 0) + queue_order = sorted(range(settings.JOB_EVENT_WORKERS), cmp=lambda x, y: -1 if x==preferred_queue else 0) for queue_actual in queue_order: try: worker_actual = worker_queues[queue_actual] @@ -153,7 +151,7 @@ class CallbackReceiver(object): if message['event'] == 'playbook_on_stats': job_parent_events = {} - actual_queue = self.write_queue_worker(total_messages % WORKERS, worker_queues, message) + actual_queue = self.write_queue_worker(total_messages % settings.JOB_EVENT_WORKERS, worker_queues, message) # NOTE: It might be better to recycle the entire callback receiver process if one or more of the queues are too full # the drawback is that if we under extremely high load we may be legitimately taking a while to process messages if actual_queue is None: @@ -282,7 +280,6 @@ class CallbackReceiver(object): return None def callback_worker(self, queue_actual, idx): - messages_processed = 0 while True: try: message = queue_actual.get(block=True, timeout=1) @@ -292,10 +289,6 @@ class CallbackReceiver(object): logger.error("Exception on listen socket, restarting: " + str(e)) break self.process_job_event(message) - messages_processed += 1 - if messages_processed >= settings.JOB_EVENT_RECYCLE_THRESHOLD: - logger.info("Shutting down message receiver") - break class Command(NoArgsCommand): ''' diff --git a/awx/main/migrations/0033_v303_v245_host_variable_fix.py b/awx/main/migrations/0033_v303_v245_host_variable_fix.py new file mode 100644 index 0000000000..fad3545b65 --- /dev/null +++ b/awx/main/migrations/0033_v303_v245_host_variable_fix.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations +from awx.main.migrations import _migration_utils as migration_utils + + +def update_dashed_host_variables(apps, schema_editor): + Host = apps.get_model('main', 'Host') + for host in Host.objects.filter(variables='---'): + host.variables = '' + host.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0032_v302_credential_permissions_update'), + ] + + operations = [ + migrations.RunPython(migration_utils.set_current_apps_for_migrations), + migrations.RunPython(update_dashed_host_variables), + ] diff --git a/awx/main/tasks.py b/awx/main/tasks.py index c99f043e1a..c76107d601 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -575,7 +575,7 @@ class BaseTask(Task): instance = self.update_model(instance.pk, status='running', output_replacements=output_replacements) while child.isalive(): - result_id = child.expect(expect_list, timeout=pexpect_timeout) + result_id = child.expect(expect_list, timeout=pexpect_timeout, searchwindowsize=100) if result_id in expect_passwords: child.sendline(expect_passwords[result_id]) if logfile_pos != logfile.tell(): diff --git a/awx/main/tests/functional/test_rbac_job_start.py b/awx/main/tests/functional/test_rbac_job_start.py index 18060126e1..c934973cf4 100644 --- a/awx/main/tests/functional/test_rbac_job_start.py +++ b/awx/main/tests/functional/test_rbac_job_start.py @@ -2,11 +2,7 @@ import pytest from awx.main.models.inventory import Inventory from awx.main.models.credential import Credential -from awx.main.models.jobs import JobTemplate - -@pytest.fixture -def machine_credential(): - return Credential.objects.create(name='machine-cred', kind='ssh', username='test_user', password='pas4word') +from awx.main.models.jobs import JobTemplate, Job @pytest.mark.django_db @pytest.mark.job_permissions @@ -45,3 +41,52 @@ def test_inventory_use_access(inventory, user): inventory.use_role.members.add(common_user) assert common_user.can_access(Inventory, 'use', inventory) + +@pytest.mark.django_db +class TestJobRelaunchAccess: + @pytest.fixture + def job_no_prompts(self, machine_credential, inventory): + jt = JobTemplate.objects.create(name='test-job_template', credential=machine_credential, inventory=inventory) + return jt.create_unified_job() + + @pytest.fixture + def job_with_prompts(self, machine_credential, inventory, organization): + jt = JobTemplate.objects.create( + name='test-job-template-prompts', credential=machine_credential, inventory=inventory, + ask_tags_on_launch=True, ask_variables_on_launch=True, ask_skip_tags_on_launch=True, + ask_limit_on_launch=True, ask_job_type_on_launch=True, ask_inventory_on_launch=True, + ask_credential_on_launch=True) + new_cred = Credential.objects.create(name='new-cred', kind='ssh', username='test_user', password='pas4word') + new_inv = Inventory.objects.create(name='new-inv', organization=organization) + return jt.create_unified_job(credential=new_cred, inventory=new_inv) + + def test_normal_relaunch_via_job_template(self, job_no_prompts, rando): + "Has JT execute_role, job unchanged relative to JT" + job_no_prompts.job_template.execute_role.members.add(rando) + assert rando.can_access(Job, 'start', job_no_prompts) + + def test_no_relaunch_without_prompted_fields_access(self, job_with_prompts, rando): + "Has JT execute_role but no use_role on inventory & credential - deny relaunch" + job_with_prompts.job_template.execute_role.members.add(rando) + assert not rando.can_access(Job, 'start', job_with_prompts) + + def test_can_relaunch_with_prompted_fields_access(self, job_with_prompts, rando): + "Has use_role on the prompted inventory & credential - allow relaunch" + job_with_prompts.job_template.execute_role.members.add(rando) + job_with_prompts.credential.use_role.members.add(rando) + job_with_prompts.inventory.use_role.members.add(rando) + assert rando.can_access(Job, 'start', job_with_prompts) + + def test_no_relaunch_after_limit_change(self, job_no_prompts, rando): + "State of the job contradicts the JT state - deny relaunch" + job_no_prompts.job_template.execute_role.members.add(rando) + job_no_prompts.limit = 'webservers' + job_no_prompts.save() + assert not rando.can_access(Job, 'start', job_no_prompts) + + def test_can_relaunch_if_limit_was_prompt(self, job_with_prompts, rando): + "Job state differs from JT, but only on prompted fields - allow relaunch" + job_with_prompts.job_template.execute_role.members.add(rando) + job_with_prompts.limit = 'webservers' + job_with_prompts.save() + assert not rando.can_access(Job, 'start', job_with_prompts) diff --git a/awx/main/tests/unit/api/test_filters.py b/awx/main/tests/unit/api/test_filters.py index 8f045db877..55ef257567 100644 --- a/awx/main/tests/unit/api/test_filters.py +++ b/awx/main/tests/unit/api/test_filters.py @@ -1,7 +1,8 @@ import pytest +from rest_framework.exceptions import PermissionDenied from awx.api.filters import FieldLookupBackend -from awx.main.models import JobTemplate +from awx.main.models import Credential, JobTemplate @pytest.mark.parametrize(u"empty_value", [u'', '']) def test_empty_in(empty_value): @@ -15,3 +16,21 @@ def test_valid_in(valid_value): field_lookup = FieldLookupBackend() value, new_lookup = field_lookup.value_to_python(JobTemplate, 'project__in', valid_value) assert 'foo' in value + +@pytest.mark.parametrize('lookup_suffix', ['', 'contains', 'startswith', 'in']) +@pytest.mark.parametrize('password_field', Credential.PASSWORD_FIELDS) +def test_filter_on_password_field(password_field, lookup_suffix): + field_lookup = FieldLookupBackend() + lookup = '__'.join(filter(None, [password_field, lookup_suffix])) + with pytest.raises(PermissionDenied) as excinfo: + field, new_lookup = field_lookup.get_field_from_lookup(Credential, lookup) + assert 'not allowed' in str(excinfo.value) + +@pytest.mark.parametrize('lookup_suffix', ['', 'contains', 'startswith', 'in']) +@pytest.mark.parametrize('password_field', Credential.PASSWORD_FIELDS) +def test_filter_on_related_password_field(password_field, lookup_suffix): + field_lookup = FieldLookupBackend() + lookup = '__'.join(filter(None, ['credential', password_field, lookup_suffix])) + with pytest.raises(PermissionDenied) as excinfo: + field, new_lookup = field_lookup.get_field_from_lookup(JobTemplate, lookup) + assert 'not allowed' in str(excinfo.value) diff --git a/awx/main/tests/unit/test_access.py b/awx/main/tests/unit/test_access.py index 000d91268c..0c2e6bb5be 100644 --- a/awx/main/tests/unit/test_access.py +++ b/awx/main/tests/unit/test_access.py @@ -8,8 +8,16 @@ from awx.main.access import ( BaseAccess, check_superuser, JobTemplateAccess, + SystemJobTemplateAccess, +) + +from awx.main.models import ( + Credential, + Inventory, + Project, + Role, + Organization, ) -from awx.main.models import Credential, Inventory, Project, Role, Organization @pytest.fixture @@ -110,3 +118,12 @@ def test_jt_can_add_bad_data(user_unit): access = JobTemplateAccess(user_unit) assert not access.can_add({'asdf': 'asdf'}) +def test_system_job_template_can_start(mocker): + user = mocker.MagicMock(spec=User, id=1, is_system_auditor=True, is_superuser=False) + assert user.is_system_auditor + access = SystemJobTemplateAccess(user) + assert not access.can_start(None) + + user.is_superuser = True + access = SystemJobTemplateAccess(user) + assert access.can_start(None) diff --git a/awx/main/tests/unit/test_settings.py b/awx/main/tests/unit/test_settings.py new file mode 100644 index 0000000000..2018771c63 --- /dev/null +++ b/awx/main/tests/unit/test_settings.py @@ -0,0 +1,11 @@ +from split_settings.tools import include + +def test_postprocess_auth_basic_enabled(): + locals().update({'__file__': __file__}) + + include('../../../settings/defaults.py', scope=locals()) + assert 'awx.api.authentication.LoggedBasicAuthentication' in locals()['REST_FRAMEWORK']['DEFAULT_AUTHENTICATION_CLASSES'] + + locals().update({'AUTH_BASIC_ENABLED': False}) + include('../../../settings/postprocess.py', scope=locals()) + assert 'awx.api.authentication.LoggedBasicAuthentication' not in locals()['REST_FRAMEWORK']['DEFAULT_AUTHENTICATION_CLASSES'] diff --git a/awx/plugins/callback/job_event_callback.py b/awx/plugins/callback/job_event_callback.py index a9c5b712ed..2049edc4b8 100644 --- a/awx/plugins/callback/job_event_callback.py +++ b/awx/plugins/callback/job_event_callback.py @@ -38,7 +38,7 @@ import os import pwd import urlparse import re -from copy import deepcopy +from copy import copy # Requests import requests @@ -228,7 +228,7 @@ class BaseCallbackModule(object): def _log_event(self, event, **event_data): if 'res' in event_data: - event_data['res'] = censor(deepcopy(event_data['res'])) + event_data['res'] = censor(copy(event_data['res'])) if self.callback_consumer_port: self._post_job_event_queue_msg(event, event_data) diff --git a/awx/plugins/inventory/azure_rm.ini.example b/awx/plugins/inventory/azure_rm.ini.example index 6ea2688efa..816da16532 100644 --- a/awx/plugins/inventory/azure_rm.ini.example +++ b/awx/plugins/inventory/azure_rm.ini.example @@ -1,5 +1,5 @@ # -# Configuration file for azure_rm_invetory.py +# Configuration file for azure_rm.py # [azure] # Control which resource groups are included. By default all resources groups are included. @@ -9,11 +9,14 @@ # Control which tags are included. Set tags to a comma separated list of keys or key:value pairs #tags= +# Control which locations are included. Set locations to a comma separated list (e.g. eastus,eastus2,westus) +#locations= + # Include powerstate. If you don't need powerstate information, turning it off improves runtime performance. include_powerstate=yes # Control grouping with the following boolean flags. Valid values: yes, no, true, false, True, False, 0, 1. group_by_resource_group=yes group_by_location=yes -group_by_security_group=no +group_by_security_group=yes group_by_tag=yes diff --git a/awx/plugins/inventory/azure_rm.py b/awx/plugins/inventory/azure_rm.py index 0554ecf879..f3c9e7c28d 100755 --- a/awx/plugins/inventory/azure_rm.py +++ b/awx/plugins/inventory/azure_rm.py @@ -76,7 +76,7 @@ required. For a specific host, this script returns the following variables: "version": "latest" }, "location": "westus", - "mac_address": "00-0D-3A-31-2C-EC", + "mac_address": "00-00-5E-00-53-FE", "name": "object-name", "network_interface": "interface-name", "network_interface_id": "/subscriptions/subscription-id/resourceGroups/galaxy-production/providers/Microsoft.Network/networkInterfaces/object-name1", @@ -115,7 +115,7 @@ When run in --list mode, instances are grouped by the following categories: - tag key - tag key_value -Control groups using azure_rm_inventory.ini or set environment variables: +Control groups using azure_rm.ini or set environment variables: AZURE_GROUP_BY_RESOURCE_GROUP=yes AZURE_GROUP_BY_LOCATION=yes @@ -130,6 +130,10 @@ Select hosts for specific tag key by assigning a comma separated list of tag key AZURE_TAGS=key1,key2,key3 +Select hosts for specific locations: + +AZURE_LOCATIONS=eastus,westus,eastus2 + Or, select hosts for specific tag key:value pairs by assigning a comma separated list key:value pairs to: AZURE_TAGS=key1:value1,key2:value2 @@ -137,12 +141,14 @@ AZURE_TAGS=key1:value1,key2:value2 If you don't need the powerstate, you can improve performance by turning off powerstate fetching: AZURE_INCLUDE_POWERSTATE=no -azure_rm_inventory.ini ----------------------- -As mentioned above you can control execution using environment variables or an .ini file. A sample -azure_rm_inventory.ini is included. The name of the .ini file is the basename of the inventory script (in this case -'azure_rm_inventory') with a .ini extension. This provides you with the flexibility of copying and customizing this -script and having matching .ini files. Go forth and customize your Azure inventory! +azure_rm.ini +------------ +As mentioned above, you can control execution using environment variables or a .ini file. A sample +azure_rm.ini is included. The name of the .ini file is the basename of the inventory script (in this case +'azure_rm') with a .ini extension. It also assumes the .ini file is alongside the script. To specify +a different path for the .ini file, define the AZURE_INI_PATH environment variable: + + export AZURE_INI_PATH=/path/to/custom.ini Powerstate: ----------- @@ -152,13 +158,13 @@ up. If the value is anything other than 'running', the machine is down, and will Examples: --------- Execute /bin/uname on all instances in the galaxy-qa resource group - $ ansible -i azure_rm_inventory.py galaxy-qa -m shell -a "/bin/uname -a" + $ ansible -i azure_rm.py galaxy-qa -m shell -a "/bin/uname -a" Use the inventory script to print instance specific information - $ contrib/inventory/azure_rm_inventory.py --host my_instance_host_name --pretty + $ contrib/inventory/azure_rm.py --host my_instance_host_name --pretty Use with a playbook - $ ansible-playbook -i contrib/inventory/azure_rm_inventory.py my_playbook.yml --limit galaxy-qa + $ ansible-playbook -i contrib/inventory/azure_rm.py my_playbook.yml --limit galaxy-qa Insecure Platform Warning @@ -180,11 +186,13 @@ Version: 1.0.0 import argparse import ConfigParser -import json +import json import os import re import sys +from distutils.version import LooseVersion + from os.path import expanduser HAS_AZURE = True @@ -195,12 +203,9 @@ try: from azure.mgmt.compute import __version__ as azure_compute_version from azure.common import AzureMissingResourceHttpError, AzureHttpError from azure.common.credentials import ServicePrincipalCredentials, UserPassCredentials - from azure.mgmt.network.network_management_client import NetworkManagementClient,\ - NetworkManagementClientConfiguration - from azure.mgmt.resource.resources.resource_management_client import ResourceManagementClient,\ - ResourceManagementClientConfiguration - from azure.mgmt.compute.compute_management_client import ComputeManagementClient,\ - ComputeManagementClientConfiguration + from azure.mgmt.network.network_management_client import NetworkManagementClient + from azure.mgmt.resource.resources.resource_management_client import ResourceManagementClient + from azure.mgmt.compute.compute_management_client import ComputeManagementClient except ImportError as exc: HAS_AZURE_EXC = exc HAS_AZURE = False @@ -219,6 +224,7 @@ AZURE_CREDENTIAL_ENV_MAPPING = dict( AZURE_CONFIG_SETTINGS = dict( resource_groups='AZURE_RESOURCE_GROUPS', tags='AZURE_TAGS', + locations='AZURE_LOCATIONS', include_powerstate='AZURE_INCLUDE_POWERSTATE', group_by_resource_group='AZURE_GROUP_BY_RESOURCE_GROUP', group_by_location='AZURE_GROUP_BY_LOCATION', @@ -226,7 +232,7 @@ AZURE_CONFIG_SETTINGS = dict( group_by_tag='AZURE_GROUP_BY_TAG' ) -AZURE_MIN_VERSION = "2016-03-30" +AZURE_MIN_VERSION = "0.30.0rc5" def azure_id_to_dict(id): @@ -362,8 +368,7 @@ class AzureRM(object): def network_client(self): self.log('Getting network client') if not self._network_client: - self._network_client = NetworkManagementClient( - NetworkManagementClientConfiguration(self.azure_credentials, self.subscription_id)) + self._network_client = NetworkManagementClient(self.azure_credentials, self.subscription_id) self._register('Microsoft.Network') return self._network_client @@ -371,16 +376,14 @@ class AzureRM(object): def rm_client(self): self.log('Getting resource manager client') if not self._resource_client: - self._resource_client = ResourceManagementClient( - ResourceManagementClientConfiguration(self.azure_credentials, self.subscription_id)) + self._resource_client = ResourceManagementClient(self.azure_credentials, self.subscription_id) return self._resource_client @property def compute_client(self): self.log('Getting compute client') if not self._compute_client: - self._compute_client = ComputeManagementClient( - ComputeManagementClientConfiguration(self.azure_credentials, self.subscription_id)) + self._compute_client = ComputeManagementClient(self.azure_credentials, self.subscription_id) self._register('Microsoft.Compute') return self._compute_client @@ -403,6 +406,7 @@ class AzureInventory(object): self.resource_groups = [] self.tags = None + self.locations = None self.replace_dash_in_groups = False self.group_by_resource_group = True self.group_by_location = True @@ -425,6 +429,9 @@ class AzureInventory(object): if self._args.tags: self.tags = self._args.tags.split(',') + if self._args.locations: + self.locations = self._args.locations.split(',') + if self._args.no_powerstate: self.include_powerstate = False @@ -462,6 +469,8 @@ class AzureInventory(object): help='Return inventory for comma separated list of resource group names') parser.add_argument('--tags', action='store', help='Return inventory for comma separated list of tag key:value pairs') + parser.add_argument('--locations', action='store', + help='Return inventory for comma separated list of locations') parser.add_argument('--no-powerstate', action='store_true', default=False, help='Do not include the power state of each virtual host') return parser.parse_args() @@ -487,7 +496,7 @@ class AzureInventory(object): except Exception as exc: sys.exit("Error: fetching virtual machines - {0}".format(str(exc))) - if self._args.host or self.tags > 0: + if self._args.host or self.tags or self.locations: selected_machines = self._selected_machines(virtual_machines) self._load_machines(selected_machines) else: @@ -524,7 +533,7 @@ class AzureInventory(object): resource_group=resource_group, mac_address=None, plan=(machine.plan.name if machine.plan else None), - virtual_machine_size=machine.hardware_profile.vm_size.value, + virtual_machine_size=machine.hardware_profile.vm_size, computer_name=machine.os_profile.computer_name, provisioning_state=machine.provisioning_state, ) @@ -576,7 +585,7 @@ class AzureInventory(object): host_vars['mac_address'] = network_interface.mac_address for ip_config in network_interface.ip_configurations: host_vars['private_ip'] = ip_config.private_ip_address - host_vars['private_ip_alloc_method'] = ip_config.private_ip_allocation_method.value + host_vars['private_ip_alloc_method'] = ip_config.private_ip_allocation_method if ip_config.public_ip_address: public_ip_reference = self._parse_ref_id(ip_config.public_ip_address.id) public_ip_address = self._network_client.public_ip_addresses.get( @@ -585,7 +594,7 @@ class AzureInventory(object): host_vars['ansible_host'] = public_ip_address.ip_address host_vars['public_ip'] = public_ip_address.ip_address host_vars['public_ip_name'] = public_ip_address.name - host_vars['public_ip_alloc_method'] = public_ip_address.public_ip_allocation_method.value + host_vars['public_ip_alloc_method'] = public_ip_address.public_ip_allocation_method host_vars['public_ip_id'] = public_ip_address.id if public_ip_address.dns_settings: host_vars['fqdn'] = public_ip_address.dns_settings.fqdn @@ -599,6 +608,8 @@ class AzureInventory(object): selected_machines.append(machine) if self.tags and self._tags_match(machine.tags, self.tags): selected_machines.append(machine) + if self.locations and machine.location in self.locations: + selected_machines.append(machine) return selected_machines def _get_security_groups(self, resource_group): @@ -676,17 +687,17 @@ class AzureInventory(object): file_settings = self._load_settings() if file_settings: for key in AZURE_CONFIG_SETTINGS: - if key in ('resource_groups', 'tags') and file_settings.get(key, None) is not None: + if key in ('resource_groups', 'tags', 'locations') and file_settings.get(key): values = file_settings.get(key).split(',') if len(values) > 0: setattr(self, key, values) - elif file_settings.get(key, None) is not None: + elif file_settings.get(key): val = self._to_boolean(file_settings[key]) setattr(self, key, val) else: env_settings = self._get_env_settings() for key in AZURE_CONFIG_SETTINGS: - if key in('resource_groups', 'tags') and env_settings.get(key, None) is not None: + if key in('resource_groups', 'tags', 'locations') and env_settings.get(key): values = env_settings.get(key).split(',') if len(values) > 0: setattr(self, key, values) @@ -719,7 +730,8 @@ class AzureInventory(object): def _load_settings(self): basename = os.path.splitext(os.path.basename(__file__))[0] - path = basename + '.ini' + default_path = os.path.join(os.path.dirname(__file__), (basename + '.ini')) + path = os.path.expanduser(os.path.expandvars(os.environ.get('AZURE_INI_PATH', default_path))) config = None settings = None try: @@ -774,11 +786,11 @@ class AzureInventory(object): def main(): if not HAS_AZURE: - sys.exit("The Azure python sdk is not installed (try 'pip install azure') - {0}".format(HAS_AZURE_EXC)) + sys.exit("The Azure python sdk is not installed (try 'pip install azure==2.0.0rc5') - {0}".format(HAS_AZURE_EXC)) - if azure_compute_version < AZURE_MIN_VERSION: - sys.exit("Expecting azure.mgmt.compute.__version__ to be >= {0}. Found version {1} " - "Do you have Azure >= 2.0.0rc2 installed?".format(AZURE_MIN_VERSION, azure_compute_version)) + if LooseVersion(azure_compute_version) != LooseVersion(AZURE_MIN_VERSION): + sys.exit("Expecting azure.mgmt.compute.__version__ to be {0}. Found version {1} " + "Do you have Azure == 2.0.0rc5 installed?".format(AZURE_MIN_VERSION, azure_compute_version)) AzureInventory() diff --git a/awx/plugins/inventory/rax.py b/awx/plugins/inventory/rax.py index f29e0e8ba0..89ff425717 100755 --- a/awx/plugins/inventory/rax.py +++ b/awx/plugins/inventory/rax.py @@ -155,8 +155,6 @@ import ConfigParser from six import iteritems -from ansible.constants import get_config, mk_boolean - try: import json except ImportError: @@ -166,11 +164,12 @@ try: import pyrax from pyrax.utils import slugify except ImportError: - print('pyrax is required for this module') - sys.exit(1) + sys.exit('pyrax is required for this module') from time import time +from ansible.constants import get_config, mk_boolean + NON_CALLABLES = (basestring, bool, dict, int, list, type(None)) @@ -227,12 +226,21 @@ def _list_into_cache(regions): prefix = get_config(p, 'rax', 'meta_prefix', 'RAX_META_PREFIX', 'meta') - networks = get_config(p, 'rax', 'access_network', 'RAX_ACCESS_NETWORK', - 'public', islist=True) try: - ip_versions = map(int, get_config(p, 'rax', 'access_ip_version', - 'RAX_ACCESS_IP_VERSION', 4, - islist=True)) + # Ansible 2.3+ + networks = get_config(p, 'rax', 'access_network', + 'RAX_ACCESS_NETWORK', 'public', value_type='list') + except TypeError: + # Ansible 2.2.x and below + networks = get_config(p, 'rax', 'access_network', + 'RAX_ACCESS_NETWORK', 'public', islist=True) + try: + try: + ip_versions = map(int, get_config(p, 'rax', 'access_ip_version', + 'RAX_ACCESS_IP_VERSION', 4, value_type='list')) + except TypeError: + ip_versions = map(int, get_config(p, 'rax', 'access_ip_version', + 'RAX_ACCESS_IP_VERSION', 4, islist=True)) except: ip_versions = [4] else: @@ -406,10 +414,9 @@ def setup(): if os.path.isfile(default_creds_file): creds_file = default_creds_file elif not keyring_username: - sys.stderr.write('No value in environment variable %s and/or no ' - 'credentials file at %s\n' - % ('RAX_CREDS_FILE', default_creds_file)) - sys.exit(1) + sys.exit('No value in environment variable %s and/or no ' + 'credentials file at %s' + % ('RAX_CREDS_FILE', default_creds_file)) identity_type = pyrax.get_setting('identity_type') pyrax.set_setting('identity_type', identity_type or 'rackspace') @@ -422,23 +429,28 @@ def setup(): else: pyrax.set_credential_file(creds_file, region=region) except Exception as e: - sys.stderr.write("%s: %s\n" % (e, e.message)) - sys.exit(1) + sys.exit("%s: %s" % (e, e.message)) regions = [] if region: regions.append(region) else: - region_list = get_config(p, 'rax', 'regions', 'RAX_REGION', 'all', - islist=True) + try: + # Ansible 2.3+ + region_list = get_config(p, 'rax', 'regions', 'RAX_REGION', 'all', + value_type='list') + except TypeError: + # Ansible 2.2.x and below + region_list = get_config(p, 'rax', 'regions', 'RAX_REGION', 'all', + islist=True) + for region in region_list: region = region.strip().upper() if region == 'ALL': regions = pyrax.regions break elif region not in pyrax.regions: - sys.stderr.write('Unsupported region %s' % region) - sys.exit(1) + sys.exit('Unsupported region %s' % region) elif region not in regions: regions.append(region) diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 2998d15bb7..6711a5872b 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -438,6 +438,9 @@ AWX_TASK_ENV = {} # before it recycles JOB_EVENT_RECYCLE_THRESHOLD = 3000 +# Number of workers used to proecess job events in parallel +JOB_EVENT_WORKERS = 4 + # Maximum number of job events that can be waiting on a single worker queue before # it can be skipped as too busy JOB_EVENT_MAX_QUEUE_SIZE = 100 @@ -529,6 +532,7 @@ INV_ENV_VARIABLE_BLACKLIST = ("HOME", "USER", "_", "TERM") # http://docs.aws.amazon.com/general/latest/gr/rande.html#ec2_region EC2_REGION_NAMES = { 'us-east-1': 'US East (Northern Virginia)', + 'us-east-2': 'US East (Ohio)', 'us-west-2': 'US West (Oregon)', 'us-west-1': 'US West (Northern California)', 'eu-central-1': 'EU (Frankfurt)', @@ -537,6 +541,7 @@ EC2_REGION_NAMES = { 'ap-southeast-2': 'Asia Pacific (Sydney)', 'ap-northeast-1': 'Asia Pacific (Tokyo)', 'ap-northeast-2': 'Asia Pacific (Seoul)', + 'ap-south-1': 'Asia Pacific (Mumbai)', 'sa-east-1': 'South America (Sao Paulo)', 'us-gov-west-1': 'US West (GovCloud)', 'cn-north-1': 'China (Beijing)', @@ -676,7 +681,7 @@ OPENSTACK_INSTANCE_ID_VAR = 'openstack.id' # ----- Foreman ----- # --------------------- SATELLITE6_ENABLED_VAR = 'foreman.enabled' -SATELLITE6_ENABLED_VALUE = 'true' +SATELLITE6_ENABLED_VALUE = 'True' SATELLITE6_GROUP_FILTER = r'^.+$' SATELLITE6_HOST_FILTER = r'^.+$' SATELLITE6_EXCLUDE_EMPTY_GROUPS = True diff --git a/awx/settings/postprocess.py b/awx/settings/postprocess.py index 544758e04f..fd54fd5050 100644 --- a/awx/settings/postprocess.py +++ b/awx/settings/postprocess.py @@ -31,4 +31,4 @@ if not all([SOCIAL_AUTH_SAML_SP_ENTITY_ID, SOCIAL_AUTH_SAML_SP_PUBLIC_CERT, AUTHENTICATION_BACKENDS = [x for x in AUTHENTICATION_BACKENDS if x != 'awx.sso.backends.SAMLAuth'] if not AUTH_BASIC_ENABLED: - REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [x for x in REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] if x != 'rest_framework.authentication.BasicAuthentication'] + REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] = [x for x in REST_FRAMEWORK['DEFAULT_AUTHENTICATION_CLASSES'] if x != 'awx.api.authentication.LoggedBasicAuthentication'] diff --git a/awx/sso/__init__.py b/awx/sso/__init__.py index 347aedfeee..6596e4bf78 100644 --- a/awx/sso/__init__.py +++ b/awx/sso/__init__.py @@ -8,7 +8,7 @@ import threading xmlsec_init_lock = threading.Lock() xmlsec_initialized = False -import dm.xmlsec.binding +import dm.xmlsec.binding # noqa original_xmlsec_initialize = dm.xmlsec.binding.initialize def xmlsec_initialize(*args, **kwargs): diff --git a/awx/ui/client/src/controllers/Projects.js b/awx/ui/client/src/controllers/Projects.js index 60692d581e..fce2ba8bde 100644 --- a/awx/ui/client/src/controllers/Projects.js +++ b/awx/ui/client/src/controllers/Projects.js @@ -596,6 +596,16 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, }); }); + if ($scope.pathsReadyRemove) { + $scope.pathsReadyRemove(); + } + $scope.pathsReadyRemove = $scope.$on('pathsReady', function () { + CreateSelect2({ + element: '#local-path-select', + multiple: false + }); + }); + // After the project is loaded, retrieve each related set if ($scope.projectLoadedRemove) { $scope.projectLoadedRemove(); @@ -623,6 +633,7 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, $scope.project_local_paths = opts; $scope.local_path = $scope.project_local_paths[0]; $scope.base_dir = 'You do not have access to view this property'; + $scope.$emit('pathsReady'); } LookUpInit({ @@ -718,11 +729,6 @@ export function ProjectsEdit($scope, $rootScope, $compile, $location, $log, multiple: false }); - CreateSelect2({ - element: '#local-path-select', - multiple: false - }); - $scope.scmBranchLabel = ($scope.scm_type.value === 'svn') ? 'Revision #' : 'SCM Branch'; // Initialize related search functions. Doing it here to make sure relatedSets object is populated. diff --git a/awx/ui/client/src/forms/Users.js b/awx/ui/client/src/forms/Users.js index ddcda6e296..2ce2f69712 100644 --- a/awx/ui/client/src/forms/Users.js +++ b/awx/ui/client/src/forms/Users.js @@ -141,7 +141,7 @@ export default open: false, index: false, actions: {}, - + emptyListText: 'This user is not a member of any teams', fields: { name: { key: true, diff --git a/awx/ui/client/src/helpers/ProjectPath.js b/awx/ui/client/src/helpers/ProjectPath.js index 91761fce0e..5fdc619aea 100644 --- a/awx/ui/client/src/helpers/ProjectPath.js +++ b/awx/ui/client/src/helpers/ProjectPath.js @@ -78,6 +78,7 @@ export default // trigger display of alert block when scm_type == manual scope.showMissingPlaybooksAlert = true; } + scope.$emit('pathsReady'); }) .error(function (data, status) { ProcessErrors(scope, data, status, null, { hdr: 'Error!', diff --git a/awx/ui/client/src/helpers/refresh.js b/awx/ui/client/src/helpers/refresh.js index 5755cd934e..57c806f64a 100644 --- a/awx/ui/client/src/helpers/refresh.js +++ b/awx/ui/client/src/helpers/refresh.js @@ -71,7 +71,27 @@ export default // if you're editing an object, make sure you're on the right // page to display the element you are editing - if (scope.addedItem) { + if (params.fromSearch) { + var url = params.url; + // for a search, we want to make sure to get the first page of + // results + if (url.indexOf("page=") > -1) { + // if the url includes a page, remove that part + var urlArr = url.split("page="); + var afterPageUrlArr = urlArr[1].split("&"); + + if (afterPageUrlArr.length > 1) { + // if there's stuff after the page part, + // put that back in + afterPageUrlArr.shift(); + url = urlArr[0] + + afterPageUrlArr.join("&"); + } else { + url = urlArr[0]; + } + } + getPage(url); + } else if (scope.addedItem) { id = scope.addedItem + ""; delete scope.addedItem; $rootScope.rowBeingEdited = id; diff --git a/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js index d1272f508b..627e253de0 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-add.controller.js @@ -5,10 +5,8 @@ *************************************************/ export default - ['$state', '$stateParams', '$scope', 'GroupForm', 'CredentialList', 'inventoryScriptsListObject', 'ParseTypeChange', 'GenerateForm', 'inventoryData', 'LookUpInit', - 'GroupManageService', 'GetChoices', 'GetBasePath', 'CreateSelect2', 'GetSourceTypeOptions', - function($state, $stateParams, $scope, GroupForm, CredentialList, InventoryScriptsList, ParseTypeChange, GenerateForm, inventoryData, LookUpInit, - GroupManageService, GetChoices, GetBasePath, CreateSelect2, GetSourceTypeOptions){ + ['$state', '$stateParams', '$scope', 'GroupForm', 'CredentialList', 'inventoryScriptsListObject', 'ParseTypeChange', 'GenerateForm', 'inventoryData', 'LookUpInit', 'GroupManageService', 'GetChoices', 'GetBasePath', 'CreateSelect2', 'GetSourceTypeOptions','ToJSON', + function($state, $stateParams, $scope, GroupForm, CredentialList, InventoryScriptsList, ParseTypeChange, GenerateForm, inventoryData, LookUpInit, GroupManageService, GetChoices, GetBasePath, CreateSelect2, GetSourceTypeOptions, ToJSON){ var generator = GenerateForm, form = GroupForm(); @@ -20,10 +18,11 @@ $state.go('^'); }; $scope.formSave = function(){ - var params, source; + var params, source, + json_data = ToJSON($scope.parseType, $scope.variables, true); // group fields var group = { - variables: $scope.variables === '---' || $scope.variables === '{}' ? null : $scope.variables, + variables: json_data, name: $scope.name, description: $scope.description, inventory: inventoryData.id diff --git a/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js b/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js index 941789f39d..2b7deefad4 100644 --- a/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js +++ b/awx/ui/client/src/inventories/manage/groups/groups-edit.controller.js @@ -7,10 +7,10 @@ export default ['$state', '$stateParams', '$scope', 'GroupForm', 'CredentialList', 'inventoryScriptsListObject', 'ToggleNotification', 'ParseVariableString', 'ParseTypeChange', 'GenerateForm', 'LookUpInit', 'RelatedSearchInit', 'RelatedPaginateInit', 'NotificationsListInit', - 'GroupManageService','GetChoices', 'GetBasePath', 'CreateSelect2', 'GetSourceTypeOptions', 'groupData', 'inventorySourceData', + 'GroupManageService','GetChoices', 'GetBasePath', 'CreateSelect2', 'GetSourceTypeOptions', 'groupData', 'inventorySourceData', 'ToJSON', function($state, $stateParams, $scope, GroupForm, CredentialList, InventoryScriptsList, ToggleNotification, ParseVariableString, ParseTypeChange, GenerateForm, LookUpInit, RelatedSearchInit, RelatedPaginateInit, NotificationsListInit, - GroupManageService, GetChoices, GetBasePath, CreateSelect2, GetSourceTypeOptions, groupData, inventorySourceData){ + GroupManageService, GetChoices, GetBasePath, CreateSelect2, GetSourceTypeOptions, groupData, inventorySourceData, ToJSON){ var generator = GenerateForm, form = GroupForm(); @@ -22,15 +22,18 @@ $state.go('^'); }; $scope.formSave = function(){ - var params, source; + var params, source, + json_data = ToJSON($scope.parseType, $scope.variables, true); + // group fields var group = { - variables: $scope.variables === '---' || $scope.variables === '{}' ? null : $scope.variables, + variables: json_data, name: $scope.name, description: $scope.description, inventory: $scope.inventory, id: groupData.id }; + if ($scope.source){ // inventory_source fields params = { diff --git a/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js b/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js index d0dd6c3c86..1958a24353 100644 --- a/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js +++ b/awx/ui/client/src/inventories/manage/hosts/hosts-add.controller.js @@ -5,8 +5,8 @@ *************************************************/ export default - ['$state', '$stateParams', '$scope', 'HostForm', 'ParseTypeChange', 'GenerateForm', 'HostManageService', - function($state, $stateParams, $scope, HostForm, ParseTypeChange, GenerateForm, HostManageService){ + ['$state', '$stateParams', '$scope', 'HostForm', 'ParseTypeChange', 'GenerateForm', 'HostManageService', 'ToJSON', + function($state, $stateParams, $scope, HostForm, ParseTypeChange, GenerateForm, HostManageService, ToJSON){ var generator = GenerateForm, form = HostForm; $scope.parseType = 'yaml'; @@ -17,8 +17,9 @@ $scope.host.enabled = !$scope.host.enabled; }; $scope.formSave = function(){ - var params = { - variables: $scope.variables === '---' || $scope.variables === '{}' ? null : $scope.variables, + var json_data = ToJSON($scope.parseType, $scope.variables, true), + params = { + variables: json_data,// $scope.variables === '---' || $scope.variables === '{}' ? null : $scope.variables, name: $scope.name, description: $scope.description, enabled: $scope.host.enabled, diff --git a/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js b/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js index 9098e53333..7480349505 100644 --- a/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js +++ b/awx/ui/client/src/inventories/manage/hosts/hosts-edit.controller.js @@ -5,8 +5,8 @@ *************************************************/ export default - ['$state', '$stateParams', '$scope', 'HostForm', 'ParseTypeChange', 'GenerateForm', 'HostManageService', 'host', - function($state, $stateParams, $scope, HostForm, ParseTypeChange, GenerateForm, HostManageService, host){ + ['$state', '$stateParams', '$scope', 'HostForm', 'ParseTypeChange', 'GenerateForm', 'HostManageService', 'host', 'ParseVariableString', 'ToJSON', + function($state, $stateParams, $scope, HostForm, ParseTypeChange, GenerateForm, HostManageService, host, ParseVariableString, ToJSON){ var generator = GenerateForm, form = HostForm; $scope.parseType = 'yaml'; @@ -17,9 +17,10 @@ $scope.host.enabled = !$scope.host.enabled; }; $scope.formSave = function(){ - var host = { + var json_data = ToJSON($scope.parseType, $scope.variables, true), + host = { id: $scope.host.id, - variables: $scope.variables === '---' || $scope.variables === '{}' ? null : $scope.variables, + variables: json_data, name: $scope.name, description: $scope.description, enabled: $scope.host.enabled @@ -31,12 +32,12 @@ var init = function(){ $scope.host = host; generator.inject(form, {mode: 'edit', related: false, id: 'Inventory-hostManage--panel', scope: $scope}); - $scope.variables = host.variables === '' ? '---' : host.variables; + $scope.variables = host.variables === '' ? '---' : ParseVariableString(host.variables); $scope.name = host.name; $scope.description = host.description; ParseTypeChange({ scope: $scope, - field_id: 'host_variables', + field_id: 'host_variables' }); }; init(); diff --git a/awx/ui/client/src/job-detail/host-event/host-event.block.less b/awx/ui/client/src/job-detail/host-event/host-event.block.less index 6edfc450ec..b22d52d36b 100644 --- a/awx/ui/client/src/job-detail/host-event/host-event.block.less +++ b/awx/ui/client/src/job-detail/host-event/host-event.block.less @@ -125,6 +125,8 @@ .OnePlusTwo-left--detailsRow; } .HostEvent-field--content{ + word-wrap: break-word; + max-width: 13em; flex: 0 1 13em; } .HostEvent-details--left, .HostEvent-details--right{ @@ -138,6 +140,7 @@ flex: 0 1 25em; } .HostEvent-field--content{ + max-width: 15em; flex: 0 1 15em; align-self: flex-end; } diff --git a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js index 3968eaf484..6c3d861afa 100644 --- a/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js +++ b/awx/ui/client/src/job-submission/job-submission-factories/launchjob.factory.js @@ -130,12 +130,20 @@ export default goToJobDetails('managementJobStdout'); } else if(_.has(data, 'project_update')) { - if($state.current.name !== 'projects') { + // If we are on the projects list or any child state of that list + // then we want to stay on that page. Otherwise go to the stdout + // view. + if(!$state.includes('projects')) { goToJobDetails('scmUpdateStdout'); } } else if(_.has(data, 'inventory_update')) { - goToJobDetails('inventorySyncStdout'); + // If we are on the inventory manage page or any child state of that + // page then we want to stay on that page. Otherwise go to the stdout + // view. + if(!$state.includes('inventoryManage')) { + goToJobDetails('inventorySyncStdout'); + } } } if(scope.clearDialog) { diff --git a/awx/ui/client/src/job-templates/copy/job-templates-copy.service.js b/awx/ui/client/src/job-templates/copy/job-templates-copy.service.js index 53e86a78bb..a6f937cd58 100644 --- a/awx/ui/client/src/job-templates/copy/job-templates-copy.service.js +++ b/awx/ui/client/src/job-templates/copy/job-templates-copy.service.js @@ -5,39 +5,56 @@ *************************************************/ export default - ['$rootScope', 'Rest', 'ProcessErrors', 'GetBasePath', 'moment', - function($rootScope, Rest, ProcessErrors, GetBasePath, moment){ - return { - get: function(id){ - var defaultUrl = GetBasePath('job_templates') + '?id=' + id; - Rest.setUrl(defaultUrl); - return Rest.get() - .success(function(res){ - return res; - }) - .error(function(res, status){ - ProcessErrors($rootScope, res, status, null, {hdr: 'Error!', - msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status}); - }); - }, - set: function(data){ - var defaultUrl = GetBasePath('job_templates'); - Rest.setUrl(defaultUrl); - var name = this.buildName(data.results[0].name); - data.results[0].name = name + ' @ ' + moment().format('h:mm:ss a'); // 2:49:11 pm - return Rest.post(data.results[0]) - .success(function(res){ - return res; - }) - .error(function(res, status){ - ProcessErrors($rootScope, res, status, null, {hdr: 'Error!', - msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status}); - }); - }, - buildName: function(name){ - var result = name.split('@')[0]; - return result; - } - }; - } - ]; + ['$rootScope', 'Rest', 'ProcessErrors', 'GetBasePath', 'moment', + function($rootScope, Rest, ProcessErrors, GetBasePath, moment){ + return { + get: function(id){ + var defaultUrl = GetBasePath('job_templates') + '?id=' + id; + Rest.setUrl(defaultUrl); + return Rest.get() + .success(function(res){ + return res; + }) + .error(function(res, status){ + ProcessErrors($rootScope, res, status, null, {hdr: 'Error!', + msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status}); + }); + }, + getSurvey: function(endpoint){ + Rest.setUrl(endpoint); + return Rest.get(); + }, + copySurvey: function(source, target){ + return this.getSurvey(source.related.survey_spec).success( (data) => { + Rest.setUrl(target.related.survey_spec); + return Rest.post(data); + }); + }, + set: function(data){ + var defaultUrl = GetBasePath('job_templates'); + var self = this; + Rest.setUrl(defaultUrl); + var name = this.buildName(data.results[0].name); + data.results[0].name = name + ' @ ' + moment().format('h:mm:ss a'); // 2:49:11 pm + return Rest.post(data.results[0]) + .success(function(job_template_res){ + // also copy any associated survey_spec + if (data.results[0].related.survey_spec){ + return self.copySurvey(data.results[0], job_template_res).success( () => job_template_res); + } + else{ + return job_template_res; + } + }) + .error(function(res, status){ + ProcessErrors($rootScope, res, status, null, {hdr: 'Error!', + msg: 'Call to '+ defaultUrl + ' failed. Return status: '+ status}); + }); + }, + buildName: function(name){ + var result = name.split('@')[0]; + return result; + } + }; + } + ]; diff --git a/awx/ui/client/src/login/authenticationServices/pendo.service.js b/awx/ui/client/src/login/authenticationServices/pendo.service.js index 5e034eaa87..10cdbd33d8 100644 --- a/awx/ui/client/src/login/authenticationServices/pendo.service.js +++ b/awx/ui/client/src/login/authenticationServices/pendo.service.js @@ -13,6 +13,7 @@ export default return { setPendoOptions: function (config) { var tower_version = config.version.split('-')[0], + trial = (config.trial) ? config.trial : false, options = { visitor: { id: null, @@ -24,7 +25,7 @@ export default planLevel: config.license_type, planPrice: config.instance_count, creationDate: config.license_date, - trial: config.trial, + trial: trial, tower_version: tower_version, ansible_version: config.ansible_version } @@ -92,49 +93,18 @@ export default return deferred.promise; }, - getConfig: function () { - var config = ConfigService.get(), - deferred = $q.defer(); - if(_.isEmpty(config)){ - var url = GetBasePath('config'); - Rest.setUrl(url); - var promise = Rest.get(); - promise.then(function (response) { - config = response.data.license_info; - config.analytics_status = response.data.analytics_status; - config.version = response.data.version; - config.ansible_version = response.data.ansible_version; - if(config.analytics_status === 'detailed' || config.analytics_status === 'anonymous'){ - $pendolytics.bootstrap(); - deferred.resolve(config); - } - else { - deferred.reject('Pendo is turned off.'); - } - }); - promise.catch(function (response) { - ProcessErrors($rootScope, response.data, response.status, null, { - hdr: 'Error!', - msg: 'Failed to get inventory name. GET returned status: ' + - response.status }); - deferred.reject('Could not resolve pendo config.'); - }); - } - else if(config.analytics_status === 'detailed' || config.analytics_status === 'anonymous'){ - $pendolytics.bootstrap(); - deferred.resolve(config); - } - else { - deferred.reject('Pendo is turned off.'); - } - return deferred.promise; - }, - issuePendoIdentity: function () { - var that = this; - this.getConfig().then(function(config){ - var options = that.setPendoOptions(config); - that.setRole(options).then(function(options){ + var config, + options, + c = ConfigService.get(), + config = c.license_info; + config.analytics_status = c.analytics_status; + config.version = c.version; + config.ansible_version = c.ansible_version; + if(config.analytics_status === 'detailed' || config.analytics_status === 'anonymous'){ + $pendolytics.bootstrap(); + options = this.setPendoOptions(config); + this.setRole(options).then(function(options){ $log.debug('Pendo status is '+ config.analytics_status + '. Object below:'); $log.debug(options); $pendolytics.identify(options); @@ -142,10 +112,10 @@ export default // reject function for setRole $log.debug(reason); }); - }, function(reason){ - // reject function for getConfig - $log.debug(reason); - }); + } + else { + $log.debug('Pendo is turned off.') + } } }; } diff --git a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js index 13d51cc68f..6b028a8702 100644 --- a/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js +++ b/awx/ui/client/src/organizations/linkout/organizations-linkout.route.js @@ -23,10 +23,7 @@ export default [ activityStreamTarget: 'organization' }, ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "organizations.edit"; - }, + parent: "organizations.edit", label: "USERS" }, resolve: { @@ -45,10 +42,7 @@ export default [ activityStreamTarget: 'organization' }, ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "organizations.edit"; - }, + parent: "organizations.edit", label: "TEAMS" }, resolve: { @@ -67,10 +61,7 @@ export default [ activityStreamTarget: 'organization' }, ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "organizations.edit"; - }, + parent: "organizations.edit", label: "INVENTORIES" }, resolve: { @@ -89,10 +80,7 @@ export default [ activityStreamTarget: 'organization' }, ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "organizations.edit"; - }, + parent: "organizations.edit", label: "PROJECTS" }, resolve: { @@ -111,10 +99,7 @@ export default [ activityStreamTarget: 'organization' }, ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "organizations.edit"; - }, + parent: "organizations.edit", label: "JOB TEMPLATES" }, resolve: { @@ -133,10 +118,7 @@ export default [ activityStreamTarget: 'organization' }, ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "organizations.edit"; - }, + parent: "organizations.edit", label: "ADMINS" }, resolve: { diff --git a/awx/ui/client/src/organizations/list/organizations-list.controller.js b/awx/ui/client/src/organizations/list/organizations-list.controller.js index 84510a60dc..878d23544f 100644 --- a/awx/ui/client/src/organizations/list/organizations-list.controller.js +++ b/awx/ui/client/src/organizations/list/organizations-list.controller.js @@ -8,12 +8,12 @@ export default ['$stateParams', '$scope', '$rootScope', '$location', '$log', '$compile', 'Rest', 'PaginateInit', 'SearchInit', 'OrganizationList', 'Alert', 'Prompt', 'ClearScope', 'ProcessErrors', 'GetBasePath', 'Wait', - '$state', 'generateList', 'Refresh', '$filter', + '$state', 'generateList', '$filter', function($stateParams, $scope, $rootScope, $location, $log, $compile, Rest, PaginateInit, SearchInit, OrganizationList, Alert, Prompt, ClearScope, ProcessErrors, GetBasePath, Wait, - $state, generateList, Refresh, $filter) { + $state, generateList, $filter) { ClearScope(); @@ -70,19 +70,14 @@ export default ['$stateParams', '$scope', '$rootScope', '$location', }; $scope.$on("ReloadOrgListView", function() { - var url = GetBasePath('organizations') + '?'; - if ($state.$current.self.name === "organizations" || - $state.$current.self.name === "organizations.add") { - $scope.activeCard = null; - } - if ($scope[list.iterator + 'SearchFilters']){ - url = url + _.reduce($scope[list.iterator+'SearchFilters'], (result, filter) => result + '&' + filter.url, ''); - } - Refresh({ - scope: $scope, - set: list.name, - iterator: list.iterator, - url: url + Rest.setUrl($scope.current_url); + Rest.get() + .success((data) => $scope.organizations = data.results) + .error(function(data, status) { + ProcessErrors($scope, data, status, null, { + hdr: 'Error!', + msg: 'Call to ' + defaultUrl + ' failed. DELETE returned status: ' + status + }); }); }); @@ -158,7 +153,7 @@ export default ['$stateParams', '$scope', '$rootScope', '$location', }); // grab the pagination elements, move, destroy list generator elements $('#organization-pagination').appendTo('#OrgCards'); - $('tag-search').appendTo('.OrgCards-search'); + $('#organizations tag-search').appendTo('.OrgCards-search'); $('#organizations-list').remove(); PaginateInit({ diff --git a/awx/ui/client/src/organizations/list/organizations-list.route.js b/awx/ui/client/src/organizations/list/organizations-list.route.js index c99604eecb..c965686317 100644 --- a/awx/ui/client/src/organizations/list/organizations-list.route.js +++ b/awx/ui/client/src/organizations/list/organizations-list.route.js @@ -17,10 +17,7 @@ export default { activityStreamTarget: 'organization' }, ncyBreadcrumb: { - parent: function($scope) { - $scope.$parent.$emit("ReloadOrgListView"); - return "setup"; - }, + parent: "setup", label: "ORGANIZATIONS" } }; diff --git a/awx/ui/client/src/search/tagSearch.controller.js b/awx/ui/client/src/search/tagSearch.controller.js index e50a25670e..0209329110 100644 --- a/awx/ui/client/src/search/tagSearch.controller.js +++ b/awx/ui/client/src/search/tagSearch.controller.js @@ -72,7 +72,8 @@ export default ['$scope', 'Refresh', 'tagSearchService', '$stateParams', scope: listScope, set: set, iterator: iterator, - url: url + url: url, + fromSearch: true }); listScope.$on('PostRefresh', function() { diff --git a/requirements/requirements.txt b/requirements/requirements.txt index a6bda0f61c..b2bfa05c67 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -2,10 +2,10 @@ git+https://github.com/chrismeyersfsu/ansiconv.git@tower_1.0.0#egg=ansiconv amqp==1.4.5 anyjson==0.3.3 appdirs==1.4.0 -azure==2.0.0rc2 +azure==2.0.0rc5 Babel==2.2.0 billiard==3.3.0.16 -boto==2.40.0 +boto==2.43.0 celery==3.1.10 cliff==1.15.0 cmd2==0.6.8 @@ -113,7 +113,7 @@ rax-default-network-flags-python-novaclient-ext==0.3.2 rax-scheduled-images-python-novaclient-ext==0.3.1 redis==2.10.3 requests-oauthlib==0.5.0 -requests==2.9.1 +requests==2.11.0 requestsexceptions==1.1.1 shade==1.4.0 simplejson==3.8.1 diff --git a/requirements/requirements_ansible.txt b/requirements/requirements_ansible.txt index b35cb6fcbb..4d14cd380e 100644 --- a/requirements/requirements_ansible.txt +++ b/requirements/requirements_ansible.txt @@ -1,9 +1,9 @@ anyjson==0.3.3 apache-libcloud==0.20.1 appdirs==1.4.0 -azure==2.0.0rc2 +azure==2.0.0rc5 Babel==2.2.0 -boto==2.40.0 +boto==2.43.0 cliff==1.15.0 cmd2==0.6.8 cryptography==1.3.2 @@ -69,7 +69,7 @@ rackspace-auth-openstack==1.3 rackspace-novaclient==1.5 rax-default-network-flags-python-novaclient-ext==0.3.2 rax-scheduled-images-python-novaclient-ext==0.3.1 -requests==2.5.1 +requests==2.11.0 requestsexceptions==1.1.1 shade==1.4.0 simplejson==3.8.1 diff --git a/setup.py b/setup.py index e6e25d4eec..ff268cd81d 100755 --- a/setup.py +++ b/setup.py @@ -79,13 +79,13 @@ setup( name='ansible-tower', version=__version__.split("-")[0], # FIXME: Should keep full version here? author='Ansible, Inc.', - author_email='support@ansible.com', + author_email='info@ansible.com', description='ansible-tower: API, UI and Task Engine for Ansible', - long_description='AWX provides a web-based user interface, REST API and ' + long_description='Ansible Tower provides a web-based user interface, REST API and ' 'task engine built on top of Ansible', license='Proprietary', keywords='ansible', - url='http://github.com/ansible/ansible-commander', + url='http://github.com/ansible/ansible-tower', packages=['awx'], include_package_data=True, zip_safe=False, diff --git a/tools/docker-compose/ansible_tower.egg-info/PKG-INFO b/tools/docker-compose/ansible_tower.egg-info/PKG-INFO index 61643c7c28..0d78373ace 100644 --- a/tools/docker-compose/ansible_tower.egg-info/PKG-INFO +++ b/tools/docker-compose/ansible_tower.egg-info/PKG-INFO @@ -2,11 +2,11 @@ Metadata-Version: 1.1 Name: ansible-tower Version: 3.0.0-0.devel Summary: ansible-tower: API, UI and Task Engine for Ansible -Home-page: http://github.com/ansible/ansible-commander +Home-page: http://github.com/ansible/ansible-tower Author: Ansible, Inc. -Author-email: support@ansible.com +Author-email: info@ansible.com License: Proprietary -Description: AWX provides a web-based user interface, REST API and task engine built on top of Ansible +Description: Ansible Tower provides a web-based user interface, REST API and task engine built on top of Ansible Keywords: ansible Platform: UNKNOWN Classifier: Development Status :: 5 - Production/Stable