diff --git a/awx/api/views.py b/awx/api/views.py index 058ab57082..cc8288a1f7 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2377,6 +2377,7 @@ class InventoryScriptView(RetrieveAPIView): obj = self.get_object() hostname = request.query_params.get('host', '') hostvars = bool(request.query_params.get('hostvars', '')) + towervars = bool(request.query_params.get('towervars', '')) show_all = bool(request.query_params.get('all', '')) if show_all: hosts_q = dict() @@ -2441,6 +2442,10 @@ class InventoryScriptView(RetrieveAPIView): data['_meta'].setdefault('hostvars', dict()) for host in obj.hosts.filter(**hosts_q): data['_meta']['hostvars'][host.name] = host.variables_dict + if towervars: + tower_dict = dict(remote_tower_enabled=str(host.enabled).lower(), + remote_tower_id=host.id) + data['_meta']['hostvars'][host.name].update(tower_dict) return Response(data) diff --git a/awx/main/constants.py b/awx/main/constants.py index 9c0b01a3d4..aabcc0b2e3 100644 --- a/awx/main/constants.py +++ b/awx/main/constants.py @@ -5,7 +5,7 @@ import re from django.utils.translation import ugettext_lazy as _ -CLOUD_PROVIDERS = ('azure_rm', 'ec2', 'gce', 'vmware', 'openstack', 'ovirt4', 'satellite6', 'cloudforms') +CLOUD_PROVIDERS = ('azure_rm', 'ec2', 'gce', 'vmware', 'openstack', 'ovirt4', 'satellite6', 'cloudforms', 'tower') SCHEDULEABLE_PROVIDERS = CLOUD_PROVIDERS + ('custom', 'scm',) PRIVILEGE_ESCALATION_METHODS = [ ('sudo', _('Sudo')), ('su', _('Su')), ('pbrun', _('Pbrun')), ('pfexec', _('Pfexec')), ('dzdo', _('DZDO')), ('pmrun', _('Pmrun')), ('runas', _('Runas'))] ANSI_SGR_PATTERN = re.compile(r'\x1b\[[0-9;]*m') diff --git a/awx/main/managers.py b/awx/main/managers.py index 4825b33231..69d4a24b6b 100644 --- a/awx/main/managers.py +++ b/awx/main/managers.py @@ -7,7 +7,7 @@ import logging from django.db import models from django.utils.timezone import now -from django.db.models import Sum +from django.db.models import Sum, Q from django.conf import settings from awx.main.utils.filters import SmartFilter @@ -21,9 +21,9 @@ class HostManager(models.Manager): """Custom manager class for Hosts model.""" def active_count(self): - """Return count of active, unique hosts for licensing.""" + """Return count of active, unique hosts for licensing. Exclude ones source from another Tower""" try: - return self.order_by('name').distinct('name').count() + return self.filter(~Q(inventory_sources__source='tower')).order_by('name').distinct('name').count() except NotImplementedError: # For unit tests only, SQLite doesn't support distinct('name') return len(set(self.values_list('name', flat=True))) diff --git a/awx/main/migrations/0010_v322_add_support_for_ovirt4_inventory.py b/awx/main/migrations/0010_v322_add_ovirt4_tower_inventory.py similarity index 87% rename from awx/main/migrations/0010_v322_add_support_for_ovirt4_inventory.py rename to awx/main/migrations/0010_v322_add_ovirt4_tower_inventory.py index e6db61e4b8..aa00d66163 100644 --- a/awx/main/migrations/0010_v322_add_support_for_ovirt4_inventory.py +++ b/awx/main/migrations/0010_v322_add_ovirt4_tower_inventory.py @@ -14,15 +14,15 @@ class Migration(migrations.Migration): ] operations = [ - migrations.RunPython(credentialtypes.create_ovirt4_credtype), + migrations.RunPython(credentialtypes.create_ovirt4_tower_credtype), migrations.AlterField( model_name='inventorysource', name='source', - field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script'), (b'scm', 'Sourced from a Project'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'ovirt4', 'oVirt4'), (b'custom', 'Custom Script')]), + field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script'), (b'scm', 'Sourced from a Project'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'ovirt4', 'oVirt4'), (b'tower', 'Ansible Tower'), (b'custom', 'Custom Script')]), ), migrations.AlterField( model_name='inventoryupdate', name='source', - field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script'), (b'scm', 'Sourced from a Project'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'ovirt4', 'oVirt4'), (b'custom', 'Custom Script')]), + field=models.CharField(default=b'', max_length=32, blank=True, choices=[(b'', 'Manual'), (b'file', 'File, Directory or Script'), (b'scm', 'Sourced from a Project'), (b'ec2', 'Amazon EC2'), (b'gce', 'Google Compute Engine'), (b'azure_rm', 'Microsoft Azure Resource Manager'), (b'vmware', 'VMware vCenter'), (b'satellite6', 'Red Hat Satellite 6'), (b'cloudforms', 'Red Hat CloudForms'), (b'openstack', 'OpenStack'), (b'ovirt4', 'oVirt4'), (b'tower', 'Ansible Tower'), (b'custom', 'Custom Script')]), ), ] diff --git a/awx/main/migrations/0011_v322_encrypt_survey_passwords.py b/awx/main/migrations/0011_v322_encrypt_survey_passwords.py index 344aa96bc1..7140949c7e 100644 --- a/awx/main/migrations/0011_v322_encrypt_survey_passwords.py +++ b/awx/main/migrations/0011_v322_encrypt_survey_passwords.py @@ -9,7 +9,7 @@ from awx.main.migrations import _reencrypt as reencrypt class Migration(ActivityStreamDisabledMigration): dependencies = [ - ('main', '0010_v322_add_support_for_ovirt4_inventory'), + ('main', '0010_v322_add_ovirt4_tower_inventory'), ] operations = [ diff --git a/awx/main/migrations/_credentialtypes.py b/awx/main/migrations/_credentialtypes.py index e0ccb873ea..9b008a2b24 100644 --- a/awx/main/migrations/_credentialtypes.py +++ b/awx/main/migrations/_credentialtypes.py @@ -174,7 +174,7 @@ def migrate_job_credentials(apps, schema_editor): utils.get_current_apps = orig_current_apps -def create_ovirt4_credtype(apps, schema_editor): +def create_ovirt4_tower_credtype(apps, schema_editor): CredentialType.setup_tower_managed_defaults() diff --git a/awx/main/models/base.py b/awx/main/models/base.py index 93bb484a46..9c7f58d096 100644 --- a/awx/main/models/base.py +++ b/awx/main/models/base.py @@ -52,7 +52,7 @@ PROJECT_UPDATE_JOB_TYPE_CHOICES = [ (PERM_INVENTORY_CHECK, _('Check')), ] -CLOUD_INVENTORY_SOURCES = ['ec2', 'vmware', 'gce', 'azure_rm', 'openstack', 'ovirt4', 'custom', 'satellite6', 'cloudforms', 'scm',] +CLOUD_INVENTORY_SOURCES = ['ec2', 'vmware', 'gce', 'azure_rm', 'openstack', 'ovirt4', 'custom', 'satellite6', 'cloudforms', 'scm', 'tower',] VERBOSITY_CHOICES = [ (0, '0 (Normal)'), diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index e543bbb8b8..ef74d91074 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -64,6 +64,7 @@ class V1Credential(object): ('openstack', 'OpenStack'), ('ovirt4', 'oVirt4'), ('insights', 'Insights'), + ('tower', 'Ansible Tower'), ] FIELDS = { 'kind': models.CharField( @@ -1067,3 +1068,37 @@ def ovirt4(cls): } }, ) + + +@CredentialType.default +def tower(cls): + return cls( + kind='cloud', + name='Ansible Tower', + managed_by_tower=True, + inputs={ + 'fields': [{ + 'id': 'host', + 'label': 'Ansible Tower Hostname', + 'type': 'string', + 'help_text': ('The Ansible Tower base URL to authenticate with.') + }, { + 'id': 'username', + 'label': 'Username', + 'type': 'string' + }, { + 'id': 'password', + 'label': 'Password', + 'type': 'string', + 'secret': True, + }], + 'required': ['host', 'username', 'password'], + }, + injectors={ + 'env': { + 'TOWER_HOST': '{{host}}', + 'TOWER_USERNAME': '{{username}}', + 'TOWER_PASSWORD': '{{password}}', + } + }, + ) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index f5cd0d8e58..5f43975919 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -873,6 +873,7 @@ class InventorySourceOptions(BaseModel): ('cloudforms', _('Red Hat CloudForms')), ('openstack', _('OpenStack')), ('ovirt4', _('oVirt4')), + ('tower', _('Ansible Tower')), ('custom', _('Custom Script')), ] @@ -1126,6 +1127,11 @@ class InventorySourceOptions(BaseModel): """No region supprt""" return [('all', 'All')] + @classmethod + def get_tower_region_choices(self): + """No region supprt""" + return [('all', 'All')] + def clean_credential(self): if not self.source: return None @@ -1197,7 +1203,7 @@ class InventorySourceOptions(BaseModel): raise ValidationError(_('Invalid filter expression: %(filter)s') % {'filter': ', '.join(invalid_filters)}) return instance_filters - elif self.source == 'vmware': + elif self.source in ('vmware', 'tower'): return instance_filters else: return '' diff --git a/awx/main/tasks.py b/awx/main/tasks.py index 8ba58111e2..b403baff9a 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1918,6 +1918,9 @@ class RunInventoryUpdate(BaseTask): for env_k in inventory_update.source_vars_dict: if str(env_k) not in env and str(env_k) not in settings.INV_ENV_VARIABLE_BLACKLIST: env[str(env_k)] = unicode(inventory_update.source_vars_dict[env_k]) + elif inventory_update.source == 'tower': + env['TOWER_INVENTORY'] = inventory_update.instance_filters + env['TOWER_LICENSE_TYPE'] = get_licenser().validate()['license_type'] elif inventory_update.source == 'file': raise NotImplementedError('Cannot update file sources through the task system.') # add private_data_files diff --git a/awx/main/tests/functional/test_credential.py b/awx/main/tests/functional/test_credential.py index 9bcf23e198..d69ee9ce37 100644 --- a/awx/main/tests/functional/test_credential.py +++ b/awx/main/tests/functional/test_credential.py @@ -29,6 +29,7 @@ def test_default_cred_types(): 'satellite6', 'scm', 'ssh', + 'tower', 'vault', 'vmware', ] diff --git a/awx/plugins/inventory/tower.py b/awx/plugins/inventory/tower.py new file mode 100755 index 0000000000..263cd5eebf --- /dev/null +++ b/awx/plugins/inventory/tower.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2016 Red Hat, Inc. +# +# This file is part of Ansible +# +# Ansible 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 Ansible. If not, see . +# +""" +Ansible Tower/AWX dynamic inventory script +========================================== + +Generates dynamic inventory for Tower + +Author: Matthew Jones (@matburt) +""" + +import argparse +import re +import os +import sys +import json +import requests +from requests.auth import HTTPBasicAuth +from urlparse import urljoin + + +def parse_configuration(): + """ + Create command line parser for oVirt dynamic inventory script. + """ + parser = argparse.ArgumentParser( + description='Ansible dynamic inventory script for Ansible Tower.', + ) + parser.add_argument( + '--list', + action='store_true', + default=True, + help='Return all hosts known to Tower given a particular inventory', + ) + parser.parse_args() + host_name = os.environ.get("TOWER_HOST", None) + username = os.environ.get("TOWER_USERNAME", None) + password = os.environ.get("TOWER_PASSWORD", None) + ignore_ssl = os.environ.get("TOWER_IGNORE_SSL", "1").lower() in ("1", "yes", "true") + inventory = os.environ.get("TOWER_INVENTORY", None) + license_type = os.environ.get("TOWER_LICENSE_TYPE", "enterprise") + + errors = [] + if not host_name: + errors.append("Missing TOWER_HOST in environment") + if not username: + errors.append("Missing TOWER_USERNAME in environment") + if not password: + errors.append("Missing TOWER_PASSWORD in environment") + if not inventory: + errors.append("Missing TOWER_INVENTORY in environment") + if errors: + print("\n".join(errors)) + sys.exit(1) + + return dict(tower_host=host_name, + tower_user=username, + tower_pass=password, + tower_inventory=inventory, + tower_license_type=license_type, + ignore_ssl=ignore_ssl) + + +def read_tower_inventory(tower_host, tower_user, tower_pass, inventory, license_type, ignore_ssl=False): + if not re.match('(?:http|https)://', tower_host): + tower_host = "https://{}".format(tower_host) + inventory_url = urljoin(tower_host, "/api/v2/inventories/{}/script/?hostvars=1&towervars=1&all=1".format(inventory.replace('/', ''))) + config_url = urljoin(tower_host, "/api/v2/config/") + try: + if license_type != "open": + config_response = requests.get(config_url, + auth=HTTPBasicAuth(tower_user, tower_pass), + verify=not ignore_ssl) + if config_response.ok: + source_type = config_response.json()['license_info']['license_type'] + if not source_type == license_type: + print("Tower server licenses must match: source: {} local: {}".format(source_type, + license_type)) + sys.exit(1) + else: + print("Failed to validate the license of the remote Tower: {}".format(config_response.data)) + sys.exit(1) + + response = requests.get(inventory_url, + auth=HTTPBasicAuth(tower_user, tower_pass), + verify=not ignore_ssl) + if response.ok: + return response.json() + json_reason = response.json() + reason = json_reason.get('detail', 'Retrieving Tower Inventory Failed') + except requests.ConnectionError, e: + reason = "Connection to remote host failed: {}".format(e) + print(reason) + sys.exit(1) + + +def main(): + config = parse_configuration() + inventory_hosts = read_tower_inventory(config['tower_host'], + config['tower_user'], + config['tower_pass'], + config['tower_inventory'], + config['tower_license_type'], + ignore_ssl=config['ignore_ssl']) + print( + json.dumps( + inventory_hosts + ) + ) + +if __name__ == '__main__': + main() diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index b64656fdbc..d6c1b1140e 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -832,6 +832,16 @@ OVIRT4_HOST_FILTER = r'^.+$' OVIRT4_EXCLUDE_EMPTY_GROUPS = True OVIRT4_INSTANCE_ID_VAR = 'id' +# --------------------- +# ----- Tower ----- +# --------------------- +TOWER_ENABLED_VAR = 'remote_tower_enabled' +TOWER_ENABLED_VALUE = 'true' +TOWER_GROUP_FILTER = r'^.+$' +TOWER_HOST_FILTER = r'^.+$' +TOWER_EXCLUDE_EMPTY_GROUPS = True +TOWER_INSTANCE_ID_VAR = 'remote_tower_id' + # --------------------- # ----- Foreman ----- # --------------------- diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js index 248ecbff60..a08a9e4eb4 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/add/sources-add.controller.js @@ -198,7 +198,10 @@ export default ['$state', '$stateParams', '$scope', 'SourcesFormDefinition', $scope.group_by = $scope.group_by_choices; $scope.groupByPopOver = i18n._("Specify which groups to create automatically. Group names will be created similar to the options selected. If blank, all groups above are created. Refer to Ansible Tower documentation for more detail."); $scope.instanceFilterPopOver = i18n._("Provide a comma-separated list of filter expressions. Hosts are imported when all of the filters match. Refer to Ansible Tower documentation for more detail."); - } + } + if( _.get($scope, 'source') === 'tower' || _.get($scope.source, 'value') === 'tower') { + $scope.instanceFilterPopOver = i18n._("Provide the name or id of the remote Tower inventory to be imported."); + } CreateSelect2({ element: '#inventory_source_group_by', multiple: true, diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js index 4728e2747a..4b7b9c47aa 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/edit/sources-edit.controller.js @@ -275,7 +275,7 @@ export default ['$state', '$stateParams', '$scope', 'ParseVariableString', i18n._("for a complete list of supported filters.") + "

"; } - if( _.get($scope, 'source') === 'vmware' || _.get($scope.source, 'value') === 'vmware') { + if( _.get($scope, 'source') === 'vmware' || _.get($scope.source, 'value') === 'vmware') { add_new = true; $scope.group_by_choices = (inventorySourceData.group_by) ? inventorySourceData.group_by.split(',') .map((i) => ({name: i, label: i, value: i})) : []; @@ -283,6 +283,9 @@ export default ['$state', '$stateParams', '$scope', 'ParseVariableString', $scope.groupByPopOver = i18n._(`Specify which groups to create automatically. Group names will be created similar to the options selected. If blank, all groups above are created. Refer to Ansible Tower documentation for more detail.`); $scope.instanceFilterPopOver = i18n._(`Provide a comma-separated list of filter expressions. Hosts are imported when all of the filters match. Refer to Ansible Tower documentation for more detail.`); } + if( _.get($scope, 'source') === 'tower' || _.get($scope.source, 'value') === 'tower') { + $scope.instanceFilterPopOver = i18n._(`Provide the name or id of the remote Tower inventory to be imported.`); + } CreateSelect2({ element: '#inventory_source_group_by', multiple: true, diff --git a/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js b/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js index 38a786d482..d7d6e31c2b 100644 --- a/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js +++ b/awx/ui/client/src/inventories-hosts/inventories/related/sources/sources.form.js @@ -144,7 +144,7 @@ return { instance_filters: { label: i18n._("Instance Filters"), type: 'text', - ngShow: "source && (source.value == 'ec2' || source.value == 'vmware')", + ngShow: "source && (source.value == 'ec2' || source.value == 'vmware' || source.value == 'tower')", dataTitle: i18n._('Instance Filters'), dataPlacement: 'right', awPopOverWatch: 'instanceFilterPopOver',