From 6c597ad165a0cd841abe8e2f2a2866acc2758ce6 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 25 Oct 2017 16:19:39 -0400 Subject: [PATCH 1/6] Adding initial credential and invsrc for Tower * New credential type for Tower * Inventory source definitions and migrations for Tower * Initial Tower inventory source script --- awx/main/constants.py | 2 +- ...> 0010_v322_add_ovirt4_tower_inventory.py} | 6 +- .../0011_v322_encrypt_survey_passwords.py | 2 +- awx/main/migrations/_credentialtypes.py | 2 +- awx/main/models/base.py | 2 +- awx/main/models/credential.py | 35 ++++++ awx/main/models/inventory.py | 6 + awx/plugins/inventory/tower.py | 112 ++++++++++++++++++ 8 files changed, 160 insertions(+), 7 deletions(-) rename awx/main/migrations/{0010_v322_add_support_for_ovirt4_inventory.py => 0010_v322_add_ovirt4_tower_inventory.py} (87%) create mode 100755 awx/plugins/inventory/tower.py 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/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 104caa334a..3046debd80 100644 --- a/awx/main/migrations/_credentialtypes.py +++ b/awx/main/migrations/_credentialtypes.py @@ -174,5 +174,5 @@ 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 77ce9ca3b0..e4ee83fdb7 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( @@ -1061,3 +1062,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_HOSTNAME': '{{host}}', + 'TOWER_USERNAME': '{{username}}', + 'TOWER_PASSWORD': '{{password}}' + } + }, + ) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index f5cd0d8e58..e6d2164750 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 diff --git a/awx/plugins/inventory/tower.py b/awx/plugins/inventory/tower.py new file mode 100755 index 0000000000..86121ca24e --- /dev/null +++ b/awx/plugins/inventory/tower.py @@ -0,0 +1,112 @@ +#!/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_HOSTNAME", None) + username = os.environ.get("TOWER_USERNAME", None) + password = os.environ.get("TOWER_PASSWORD", None) + ignore_ssl = os.environ.get("TOWER_IGNORE_SSL", "0").lower() in ("1", "yes", "true") + inventory = os.environ.get("TOWER_INVENTORY", None) + + errors = [] + if not host_name: + errors.append("Missing TOWER_HOSTNAME 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, + ignore_ssl=ignore_ssl) + + +def read_tower_inventory(tower_host, tower_user, tower_pass, inventory, 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".format(inventory)) + try: + 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'], + ignore_ssl=config['ignore_ssl']) + print( + json.dumps( + inventory_hosts + ) + ) + +if __name__ == '__main__': + main() From fdc7f58bb42051ab3be0c781c01d9f7b4bf1ee85 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 25 Oct 2017 16:56:26 -0400 Subject: [PATCH 2/6] Support passing instance filters to tower inventory src * Switch ignore ssl errors to default on * Application inventory source defaults for Tower src --- awx/main/models/credential.py | 2 +- awx/main/models/inventory.py | 2 +- awx/main/tasks.py | 2 ++ awx/plugins/inventory/tower.py | 2 +- awx/settings/defaults.py | 10 ++++++++++ 5 files changed, 15 insertions(+), 3 deletions(-) diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index e4ee83fdb7..c24a0b7cec 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -1092,7 +1092,7 @@ def tower(cls): 'env': { 'TOWER_HOSTNAME': '{{host}}', 'TOWER_USERNAME': '{{username}}', - 'TOWER_PASSWORD': '{{password}}' + 'TOWER_PASSWORD': '{{password}}', } }, ) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index e6d2164750..5f43975919 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -1203,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 78d832a97d..c3adb98a07 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1912,6 +1912,8 @@ 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 elif inventory_update.source == 'file': raise NotImplementedError('Cannot update file sources through the task system.') # add private_data_files diff --git a/awx/plugins/inventory/tower.py b/awx/plugins/inventory/tower.py index 86121ca24e..077aa556f9 100755 --- a/awx/plugins/inventory/tower.py +++ b/awx/plugins/inventory/tower.py @@ -54,7 +54,7 @@ def parse_configuration(): host_name = os.environ.get("TOWER_HOSTNAME", None) username = os.environ.get("TOWER_USERNAME", None) password = os.environ.get("TOWER_PASSWORD", None) - ignore_ssl = os.environ.get("TOWER_IGNORE_SSL", "0").lower() in ("1", "yes", "true") + ignore_ssl = os.environ.get("TOWER_IGNORE_SSL", "1").lower() in ("1", "yes", "true") inventory = os.environ.get("TOWER_INVENTORY", None) errors = [] diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index b64656fdbc..1255e9d21d 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 = 'status' +TOWER_ENABLED_VALUE = 'enabled' +TOWER_GROUP_FILTER = r'^.+$' +TOWER_HOST_FILTER = r'^.+$' +TOWER_EXCLUDE_EMPTY_GROUPS = True +TOWER_INSTANCE_ID_VAR = 'id' + # --------------------- # ----- Foreman ----- # --------------------- From 71e132ce0f658f8f214219acd0e2f4143fc0fdd8 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Wed, 25 Oct 2017 16:57:18 -0400 Subject: [PATCH 3/6] Show instance filter ui element with tower inventory source --- .../related/sources/add/sources-add.controller.js | 5 ++++- .../related/sources/edit/sources-edit.controller.js | 5 ++++- .../inventories/related/sources/sources.form.js | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) 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', From d282966aa19e08307936477ec33fafa7c078c1c3 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 26 Oct 2017 11:11:48 -0400 Subject: [PATCH 4/6] Use towervars to enable turning on remote tracking vars on Tower src * This allows the local Tower to track enabled state and unique instance id for each host imported from the remote Tower --- awx/api/views.py | 5 +++++ awx/plugins/inventory/tower.py | 2 +- awx/settings/defaults.py | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index 058ab57082..a7bc27a927 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=host.enabled, + remote_tower_id=host.id) + data['_meta']['hostvars'][host.name].update(tower_dict) return Response(data) diff --git a/awx/plugins/inventory/tower.py b/awx/plugins/inventory/tower.py index 077aa556f9..0fae0865b2 100755 --- a/awx/plugins/inventory/tower.py +++ b/awx/plugins/inventory/tower.py @@ -80,7 +80,7 @@ def parse_configuration(): def read_tower_inventory(tower_host, tower_user, tower_pass, inventory, 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".format(inventory)) + inventory_url = urljoin(tower_host, "/api/v2/inventories/{}/script/?hostvars=1&towervars=1".format(inventory)) try: response = requests.get(inventory_url, auth=HTTPBasicAuth(tower_user, tower_pass), diff --git a/awx/settings/defaults.py b/awx/settings/defaults.py index 1255e9d21d..d6c1b1140e 100644 --- a/awx/settings/defaults.py +++ b/awx/settings/defaults.py @@ -835,12 +835,12 @@ OVIRT4_INSTANCE_ID_VAR = 'id' # --------------------- # ----- Tower ----- # --------------------- -TOWER_ENABLED_VAR = 'status' -TOWER_ENABLED_VALUE = 'enabled' +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 = 'id' +TOWER_INSTANCE_ID_VAR = 'remote_tower_id' # --------------------- # ----- Foreman ----- From 5f3ebc26e06cde4fd2728c38c9b5fbea19d16891 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 26 Oct 2017 11:32:16 -0400 Subject: [PATCH 5/6] Adding license checks for Tower inventory source * For Tower the license must match between the source and destination * For AWX the check is disabled * Hosts imported from another Tower don't count against your license in the local Tower * Fix up some issues with enablement * Prevent slashes from being used in the instance filter * Add &all=1 filter to make sure we pick up all hosts --- awx/api/views.py | 2 +- awx/main/managers.py | 6 +++--- awx/main/tasks.py | 1 + awx/main/tests/functional/test_credential.py | 1 + awx/plugins/inventory/tower.py | 22 ++++++++++++++++++-- 5 files changed, 26 insertions(+), 6 deletions(-) diff --git a/awx/api/views.py b/awx/api/views.py index a7bc27a927..cc8288a1f7 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -2443,7 +2443,7 @@ class InventoryScriptView(RetrieveAPIView): for host in obj.hosts.filter(**hosts_q): data['_meta']['hostvars'][host.name] = host.variables_dict if towervars: - tower_dict = dict(remote_tower_enabled=host.enabled, + tower_dict = dict(remote_tower_enabled=str(host.enabled).lower(), remote_tower_id=host.id) data['_meta']['hostvars'][host.name].update(tower_dict) 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/tasks.py b/awx/main/tasks.py index c3adb98a07..e284aee63d 100644 --- a/awx/main/tasks.py +++ b/awx/main/tasks.py @@ -1914,6 +1914,7 @@ class RunInventoryUpdate(BaseTask): 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 index 0fae0865b2..1de920728f 100755 --- a/awx/plugins/inventory/tower.py +++ b/awx/plugins/inventory/tower.py @@ -56,6 +56,7 @@ def parse_configuration(): 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: @@ -74,14 +75,30 @@ def parse_configuration(): 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, ignore_ssl=False): +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".format(inventory)) + 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) @@ -101,6 +118,7 @@ def main(): config['tower_user'], config['tower_pass'], config['tower_inventory'], + config['tower_license_type'], ignore_ssl=config['ignore_ssl']) print( json.dumps( From 85be3c7692f7adaf25be48fc7f5af689cfbbca13 Mon Sep 17 00:00:00 2001 From: Matthew Jones Date: Thu, 26 Oct 2017 16:40:10 -0400 Subject: [PATCH 6/6] Align inventory variables with Ansible modules --- awx/main/models/credential.py | 2 +- awx/plugins/inventory/tower.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/awx/main/models/credential.py b/awx/main/models/credential.py index c24a0b7cec..6271cdc0de 100644 --- a/awx/main/models/credential.py +++ b/awx/main/models/credential.py @@ -1090,7 +1090,7 @@ def tower(cls): }, injectors={ 'env': { - 'TOWER_HOSTNAME': '{{host}}', + 'TOWER_HOST': '{{host}}', 'TOWER_USERNAME': '{{username}}', 'TOWER_PASSWORD': '{{password}}', } diff --git a/awx/plugins/inventory/tower.py b/awx/plugins/inventory/tower.py index 1de920728f..263cd5eebf 100755 --- a/awx/plugins/inventory/tower.py +++ b/awx/plugins/inventory/tower.py @@ -51,7 +51,7 @@ def parse_configuration(): help='Return all hosts known to Tower given a particular inventory', ) parser.parse_args() - host_name = os.environ.get("TOWER_HOSTNAME", None) + 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") @@ -60,7 +60,7 @@ def parse_configuration(): errors = [] if not host_name: - errors.append("Missing TOWER_HOSTNAME in environment") + errors.append("Missing TOWER_HOST in environment") if not username: errors.append("Missing TOWER_USERNAME in environment") if not password: