Merge pull request #5747 from john-westcott-iv/collections

Porting Collections Off of Tower CLI

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot] 2020-02-26 20:35:00 +00:00 committed by GitHub
commit 154b9c36ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1954 additions and 946 deletions

View File

@ -192,7 +192,7 @@ class APIView(views.APIView):
response.data['detail'] += ' To establish a login session, visit /api/login/.'
logger.info(status_msg)
else:
logger.warn(status_msg)
logger.warning(status_msg)
response = super(APIView, self).finalize_response(request, response, *args, **kwargs)
time_started = getattr(self, 'time_started', None)
response['X-API-Node'] = settings.CLUSTER_HOST_ID

View File

@ -2115,7 +2115,13 @@ class InventorySourceSerializer(UnifiedJobTemplateSerializer, InventorySourceOpt
def get_field_from_model_or_attrs(fd):
return attrs.get(fd, self.instance and getattr(self.instance, fd) or None)
if get_field_from_model_or_attrs('source') != 'scm':
if get_field_from_model_or_attrs('source') == 'scm':
if (('source' in attrs or 'source_project' in attrs) and
get_field_from_model_or_attrs('source_project') is None):
raise serializers.ValidationError(
{"source_project": _("Project required for scm type sources.")}
)
else:
redundant_scm_fields = list(filter(
lambda x: attrs.get(x, None),
['source_project', 'source_path', 'update_on_project_update']
@ -3716,7 +3722,7 @@ class WorkflowJobNodeSerializer(LaunchConfigurationBaseSerializer):
class Meta:
model = WorkflowJobNode
fields = ('*', 'job', 'workflow_job', '-name', '-description', 'id', 'url', 'related',
'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes',
'unified_job_template', 'success_nodes', 'failure_nodes', 'always_nodes',
'all_parents_must_converge', 'do_not_run',)
def get_related(self, obj):

View File

@ -599,9 +599,9 @@ class TestControlledBySCM:
delete(inv_src.get_absolute_url(), admin_user, expect=204)
assert scm_inventory.inventory_sources.count() == 0
def test_adding_inv_src_ok(self, post, scm_inventory, admin_user):
def test_adding_inv_src_ok(self, post, scm_inventory, project, admin_user):
post(reverse('api:inventory_inventory_sources_list', kwargs={'pk': scm_inventory.id}),
{'name': 'new inv src', 'update_on_project_update': False, 'source': 'scm', 'overwrite_vars': True},
{'name': 'new inv src', 'source_project': project.pk, 'update_on_project_update': False, 'source': 'scm', 'overwrite_vars': True},
admin_user, expect=201)
def test_adding_inv_src_prohibited(self, post, scm_inventory, project, admin_user):

View File

@ -1,7 +1,7 @@
# AWX Ansible Collection
This Ansible collection allows for easy interaction with an AWX or Ansible Tower
server in Ansible playbooks.
server via Ansible playbooks.
The previous home for this collection was in https://github.com/ansible/ansible
inside the folder `lib/ansible/modules/web_infrastructure/ansible_tower`
@ -14,30 +14,33 @@ The release 7.0.0 of the `awx.awx` collection is intended to be identical
to the content prior to the migration, aside from changes necessary to
have it function as a collection.
The following notes are changes that may require changes to playbooks.
The following notes are changes that may require changes to playbooks:
- Specifying `inputs` or `injectors` as strings in the
`tower_credential_type` module is no longer supported. Provide as dictionaries instead.
`tower_credential_type` module is no longer supported. Provide them as dictionaries instead.
- When a project is created, it will wait for the update/sync to finish by default; this can be turned off with the `wait` parameter, if desired.
- Creating a "scan" type job template is no longer supported.
- `extra_vars` in the `tower_job_launch` module worked with a list previously, but is now configured to work solely in a `dict` format.
- When the `extra_vars` parameter is used with the `tower_job_launch` module, the Job Template launch will fail unless `add_extra_vars` or `survey_enabled` is explicitly set to `True` on the Job Template.
- `tower_group` used to also service inventory sources, but this functionality has been removed from this module; use `tower_inventory_source` instead.
- Specified `tower_config` file used to handle `k=v` pairs on a single line; this is no longer supported. Please use a file formatted as `yaml`, `json` or `ini` only.
- The `variables` parameter in the `tower_group`, `tower_host` and `tower_inventory` modules are now in `dict` format and no longer supports the use of the `C(@)` syntax (for an external `vars` file).
- Some return values (e.g., `credential_type`) have been removed. Use of `id` is recommended.
## Running
To use this collection, the "old" tower-cli needs to be installed
To use this collection, the "old" `tower-cli` needs to be installed
in the virtual environment where the collection runs.
You can install it from [PyPI](https://pypi.org/project/ansible-tower-cli/).
To use this collection in AWX, you should create a custom virtual environment
to install the requirement into. NOTE: running locally, you will also need
to set the job template extra_vars to include `ansible_python_interpreter`
to be the python in that virtual environment.
To use this collection in AWX, you should create a custom virtual environment into which to install the requirements. NOTE: running locally, you will also need
to set the job template `extra_vars` to include `ansible_python_interpreter`
to be the Python in that virtual environment.
## Running Tests
Tests to verify compatibility with the most recent AWX code are
in `awx_collection/test/awx`. These tests require that python packages
in `awx_collection/test/awx`. These tests require that Python packages
are available for all of `awx`, `ansible`, `tower_cli`, and the collection
itself.
@ -45,7 +48,7 @@ itself.
The target `make prepare_collection_venv` will prepare some requirements
in the `awx_collection_test_venv` folder so that `make test_collection` can
be ran to actually run the tests. A single test can be ran via:
be executed to actually run the tests. A single test can be run via:
```
make test_collection COLLECTION_TEST_DIRS=awx_collection/test/awx/test_organization.py
@ -53,9 +56,9 @@ make test_collection COLLECTION_TEST_DIRS=awx_collection/test/awx/test_organizat
### Manually
As a faster alternative if you do not want to use the container, or
run against Ansible or tower-cli source, it is possible to set up a
working environment yourself.
As a faster alternative (if you do not want to use the container), or to
run against Ansible or `tower-cli` source, it is possible to set up a
working environment yourself:
```
mkvirtualenv my_new_venv

View File

@ -36,9 +36,6 @@ options:
- Path to the Tower or AWX config file.
type: path
requirements:
- ansible-tower-cli >= 3.0.2
notes:
- If no I(config_file) is provided we will attempt to use the tower-cli library
defaults to find your Tower host information.

View File

@ -0,0 +1,620 @@
from __future__ import absolute_import, division, print_function
__metaclass__ = type
from ansible.module_utils.basic import AnsibleModule, env_fallback
from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError
from ansible.module_utils.six import PY2
from ansible.module_utils.six.moves import StringIO
from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode
from ansible.module_utils.six.moves.urllib.error import HTTPError
from ansible.module_utils.six.moves.http_cookiejar import CookieJar
from ansible.module_utils.six.moves.configparser import ConfigParser, NoOptionError
from socket import gethostbyname
import re
from json import loads, dumps
from os.path import isfile, expanduser, split, join, exists, isdir
from os import access, R_OK, getcwd
from distutils.util import strtobool
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
class ConfigFileException(Exception):
pass
class ItemNotDefined(Exception):
pass
class TowerModule(AnsibleModule):
url = None
honorred_settings = ('host', 'username', 'password', 'verify_ssl', 'oauth_token')
host = '127.0.0.1'
username = None
password = None
verify_ssl = True
oauth_token = None
oauth_token_id = None
session = None
cookie_jar = CookieJar()
authenticated = False
config_name = 'tower_cli.cfg'
def __init__(self, argument_spec, **kwargs):
args = dict(
tower_host=dict(required=False, fallback=(env_fallback, ['TOWER_HOST'])),
tower_username=dict(required=False, fallback=(env_fallback, ['TOWER_USERNAME'])),
tower_password=dict(no_log=True, required=False, fallback=(env_fallback, ['TOWER_PASSWORD'])),
validate_certs=dict(type='bool', aliases=['tower_verify_ssl'], required=False, fallback=(env_fallback, ['TOWER_VERIFY_SSL'])),
tower_oauthtoken=dict(type='str', no_log=True, required=False, fallback=(env_fallback, ['TOWER_OAUTH_TOKEN'])),
tower_config_file=dict(type='path', required=False, default=None),
)
args.update(argument_spec)
kwargs['supports_check_mode'] = True
self.json_output = {'changed': False}
super(TowerModule, self).__init__(argument_spec=args, **kwargs)
self.load_config_files()
# Parameters specified on command line will override settings in any config
if self.params.get('tower_host'):
self.host = self.params.get('tower_host')
if self.params.get('tower_username'):
self.username = self.params.get('tower_username')
if self.params.get('tower_password'):
self.password = self.params.get('tower_password')
if self.params.get('validate_certs') is not None:
self.verify_ssl = self.params.get('validate_certs')
if self.params.get('tower_oauthtoken'):
self.oauth_token = self.params.get('tower_oauthtoken')
# Perform some basic validation
if not re.match('^https{0,1}://', self.host):
self.host = "https://{0}".format(self.host)
# Try to parse the hostname as a url
try:
self.url = urlparse(self.host)
except Exception as e:
self.fail_json(msg="Unable to parse tower_host as a URL ({1}): {0}".format(self.host, e))
# Try to resolve the hostname
hostname = self.url.netloc.split(':')[0]
try:
gethostbyname(hostname)
except Exception as e:
self.fail_json(msg="Unable to resolve tower_host ({1}): {0}".format(hostname, e))
self.session = Request(cookies=CookieJar(), validate_certs=self.verify_ssl)
def load_config_files(self):
# Load configs like TowerCLI would have from least import to most
config_files = ['/etc/tower/tower_cli.cfg', join(expanduser("~"), ".{0}".format(self.config_name))]
local_dir = getcwd()
config_files.append(join(local_dir, self.config_name))
while split(local_dir)[1]:
local_dir = split(local_dir)[0]
config_files.insert(2, join(local_dir, ".{0}".format(self.config_name)))
for config_file in config_files:
if exists(config_file) and not isdir(config_file):
# Only throw a formatting error if the file exists and is not a directory
try:
self.load_config(config_file)
except ConfigFileException:
self.fail_json('The config file {0} is not properly formatted'.format(config_file))
# If we have a specified tower config, load it
if self.params.get('tower_config_file'):
duplicated_params = []
for direct_field in ('tower_host', 'tower_username', 'tower_password', 'validate_certs', 'tower_oauthtoken'):
if self.params.get(direct_field):
duplicated_params.append(direct_field)
if duplicated_params:
self.warn((
'The parameter(s) {0} were provided at the same time as tower_config_file. '
'Precedence may be unstable, we suggest either using config file or params.'
).format(', '.join(duplicated_params)))
try:
# TODO: warn if there are conflicts with other params
self.load_config(self.params.get('tower_config_file'))
except ConfigFileException as cfe:
# Since we were told specifically to load this we want it to fail if we have an error
self.fail_json(msg=cfe)
def load_config(self, config_path):
# Validate the config file is an actual file
if not isfile(config_path):
raise ConfigFileException('The specified config file does not exist')
if not access(config_path, R_OK):
raise ConfigFileException("The specified config file cannot be read")
# Read in the file contents:
with open(config_path, 'r') as f:
config_string = f.read()
# First try to yaml load the content (which will also load json)
try:
config_data = yaml.load(config_string, Loader=yaml.SafeLoader)
# If this is an actual ini file, yaml will return the whole thing as a string instead of a dict
if type(config_data) is not dict:
raise AssertionError("The yaml config file is not properly formatted as a dict.")
except(AttributeError, yaml.YAMLError, AssertionError):
# TowerCLI used to support a config file with a missing [general] section by prepending it if missing
if '[general]' not in config_string:
config_string = '[general]{0}'.format(config_string)
config = ConfigParser()
try:
placeholder_file = StringIO(config_string)
# py2 ConfigParser has readfp, that has been deprecated in favor of read_file in py3
# This "if" removes the deprecation warning
if hasattr(config, 'read_file'):
config.read_file(placeholder_file)
else:
config.readfp(placeholder_file)
# If we made it here then we have values from reading the ini file, so let's pull them out into a dict
config_data = {}
for honorred_setting in self.honorred_settings:
try:
config_data[honorred_setting] = config.get('general', honorred_setting)
except (NoOptionError):
pass
except Exception as e:
raise ConfigFileException("An unknown exception occured trying to ini load config file: {0}".format(e))
except Exception as e:
raise ConfigFileException("An unknown exception occured trying to load config file: {0}".format(e))
# If we made it here, we have a dict which has values in it from our config, any final settings logic can be performed here
for honorred_setting in self.honorred_settings:
if honorred_setting in config_data:
# Veriffy SSL must be a boolean
if honorred_setting == 'verify_ssl':
if type(config_data[honorred_setting]) is str:
setattr(self, honorred_setting, strtobool(config_data[honorred_setting]))
else:
setattr(self, honorred_setting, bool(config_data[honorred_setting]))
else:
setattr(self, honorred_setting, config_data[honorred_setting])
def head_endpoint(self, endpoint, *args, **kwargs):
return self.make_request('HEAD', endpoint, **kwargs)
def get_endpoint(self, endpoint, *args, **kwargs):
return self.make_request('GET', endpoint, **kwargs)
def patch_endpoint(self, endpoint, *args, **kwargs):
# Handle check mode
if self.check_mode:
self.json_output['changed'] = True
self.exit_json(**self.json_output)
return self.make_request('PATCH', endpoint, **kwargs)
def post_endpoint(self, endpoint, *args, **kwargs):
# Handle check mode
if self.check_mode:
self.json_output['changed'] = True
self.exit_json(**self.json_output)
return self.make_request('POST', endpoint, **kwargs)
def delete_endpoint(self, endpoint, *args, **kwargs):
# Handle check mode
if self.check_mode:
self.json_output['changed'] = True
self.exit_json(**self.json_output)
return self.make_request('DELETE', endpoint, **kwargs)
def get_all_endpoint(self, endpoint, *args, **kwargs):
response = self.get_endpoint(endpoint, *args, **kwargs)
next_page = response['json']['next']
if response['json']['count'] > 10000:
self.fail_json(msg='The number of items being queried for is higher than 10,000.')
while next_page is not None:
next_response = self.get_endpoint(next_page)
response['json']['results'] = response['json']['results'] + next_response['json']['results']
next_page = next_response['json']['next']
return response
def get_one(self, endpoint, *args, **kwargs):
response = self.get_endpoint(endpoint, *args, **kwargs)
if response['status_code'] != 200:
self.fail_json(msg="Got a {0} response when trying to get one from {1}".format(response['status_code'], endpoint))
if 'count' not in response['json'] or 'results' not in response['json']:
self.fail_json(msg="The endpoint did not provide count and results")
if response['json']['count'] == 0:
return None
elif response['json']['count'] > 1:
self.fail_json(msg="An unexpected number of items was returned from the API ({0})".format(response['json']['count']))
return response['json']['results'][0]
def resolve_name_to_id(self, endpoint, name_or_id):
# Try to resolve the object by name
response = self.get_endpoint(endpoint, **{'data': {'name': name_or_id}})
if response['json']['count'] == 1:
return response['json']['results'][0]['id']
elif response['json']['count'] == 0:
try:
int(name_or_id)
# If we got 0 items by name, maybe they gave us an ID, let's try looking it up by ID
response = self.head_endpoint("{0}/{1}".format(endpoint, name_or_id), **{'return_none_on_404': True})
if response is not None:
return name_or_id
except ValueError:
# If we got a value error than we didn't have an integer so we can just pass and fall down to the fail
pass
self.fail_json(msg="The {0} {1} was not found on the Tower server".format(endpoint, name_or_id))
else:
self.fail_json(msg="Found too many names {0} at endpoint {1} try using an ID instead of a name".format(name_or_id, endpoint))
def make_request(self, method, endpoint, *args, **kwargs):
# In case someone is calling us directly; make sure we were given a method, let's not just assume a GET
if not method:
raise Exception("The HTTP method must be defined")
# Make sure we start with /api/vX
if not endpoint.startswith("/"):
endpoint = "/{0}".format(endpoint)
if not endpoint.startswith("/api/"):
endpoint = "/api/v2{0}".format(endpoint)
if not endpoint.endswith('/') and '?' not in endpoint:
endpoint = "{0}/".format(endpoint)
# Extract the headers, this will be used in a couple of places
headers = kwargs.get('headers', {})
# Authenticate to Tower (if we've not already done so)
if not self.authenticated:
# This method will set a cookie in the cookie jar for us
self.authenticate(**kwargs)
if self.oauth_token:
# If we have a oauth token, we just use a bearer header
headers['Authorization'] = 'Bearer {0}'.format(self.oauth_token)
# Update the URL path with the endpoint
self.url = self.url._replace(path=endpoint)
if method in ['POST', 'PUT', 'PATCH']:
headers.setdefault('Content-Type', 'application/json')
kwargs['headers'] = headers
elif kwargs.get('data'):
self.url = self.url._replace(query=urlencode(kwargs.get('data')))
data = {}
if headers.get('Content-Type', '') == 'application/json':
data = dumps(kwargs.get('data', {}))
try:
response = self.session.open(method, self.url.geturl(), headers=headers, validate_certs=self.verify_ssl, follow_redirects=True, data=data)
self.url = self.url._replace(query=None)
except(SSLValidationError) as ssl_err:
self.fail_json(msg="Could not establish a secure connection to your host ({1}): {0}.".format(self.url.netloc, ssl_err))
except(ConnectionError) as con_err:
self.fail_json(msg="There was a network error of some kind trying to connect to your host ({1}): {0}.".format(self.url.netloc, con_err))
except(HTTPError) as he:
# Sanity check: Did the server send back some kind of internal error?
if he.code >= 500:
self.fail_json(msg='The host sent back a server error ({1}): {0}. Please check the logs and try again later'.format(self.url.path, he))
# Sanity check: Did we fail to authenticate properly? If so, fail out now; this is always a failure.
elif he.code == 401:
self.fail_json(msg='Invalid Tower authentication credentials for {0} (HTTP 401).'.format(self.url.path))
# Sanity check: Did we get a forbidden response, which means that the user isn't allowed to do this? Report that.
elif he.code == 403:
self.fail_json(msg="You don't have permission to {1} to {0} (HTTP 403).".format(self.url.path, method))
# Sanity check: Did we get a 404 response?
# Requests with primary keys will return a 404 if there is no response, and we want to consistently trap these.
elif he.code == 404:
if kwargs.get('return_none_on_404', False):
return None
self.fail_json(msg='The requested object could not be found at {0}.'.format(self.url.path))
# Sanity check: Did we get a 405 response?
# A 405 means we used a method that isn't allowed. Usually this is a bad request, but it requires special treatment because the
# API sends it as a logic error in a few situations (e.g. trying to cancel a job that isn't running).
elif he.code == 405:
self.fail_json(msg="The Tower server says you can't make a request with the {0} method to this endpoing {1}".format(method, self.url.path))
# Sanity check: Did we get some other kind of error? If so, write an appropriate error message.
elif he.code >= 400:
# We are going to return a 400 so the module can decide what to do with it
page_data = he.read()
try:
return {'status_code': he.code, 'json': loads(page_data)}
# JSONDecodeError only available on Python 3.5+
except ValueError:
return {'status_code': he.code, 'text': page_data}
elif he.code == 204 and method == 'DELETE':
# A 204 is a normal response for a delete function
pass
else:
self.fail_json(msg="Unexpected return code when calling {0}: {1}".format(self.url.geturl(), he))
except(Exception) as e:
self.fail_json(msg="There was an unknown error when trying to connect to {2}: {0} {1}".format(type(e).__name__, e, self.url.geturl()))
response_body = ''
try:
response_body = response.read()
except(Exception) as e:
self.fail_json(msg="Failed to read response body: {0}".format(e))
response_json = {}
if response_body and response_body != '':
try:
response_json = loads(response_body)
except(Exception) as e:
self.fail_json(msg="Failed to parse the response json: {0}".format(e))
if PY2:
status_code = response.getcode()
else:
status_code = response.status
return {'status_code': status_code, 'json': response_json}
def authenticate(self, **kwargs):
if self.username and self.password:
# Attempt to get a token from /api/v2/tokens/ by giving it our username/password combo
# If we have a username and password, we need to get a session cookie
login_data = {
"description": "Ansible Tower Module Token",
"application": None,
"scope": "write",
}
# Post to the tokens endpoint with baisc auth to try and get a token
api_token_url = (self.url._replace(path='/api/v2/tokens/')).geturl()
try:
response = self.session.open(
'POST', api_token_url,
validate_certs=self.verify_ssl, follow_redirects=True,
force_basic_auth=True, url_username=self.username, url_password=self.password,
data=dumps(login_data), headers={'Content-Type': 'application/json'}
)
except(Exception) as e:
# Sanity check: Did the server send back some kind of internal error?
self.fail_json(msg='Failed to get token: {0}'.format(e))
token_response = None
try:
token_response = response.read()
response_json = loads(token_response)
self.oauth_token_id = response_json['id']
self.oauth_token = response_json['token']
except(Exception) as e:
self.fail_json(msg="Failed to extract token information from login response: {0}".format(e), **{'response': token_response})
# If we have neither of these, then we can try un-authenticated access
self.authenticated = True
def default_check_mode(self):
'''Execute check mode logic for Ansible Tower modules'''
if self.check_mode:
try:
result = self.get_endpoint('ping')
self.exit_json(**{'changed': True, 'tower_version': '{0}'.format(result['json']['version'])})
except(Exception) as excinfo:
self.fail_json(changed=False, msg='Failed check mode: {0}'.format(excinfo))
def delete_if_needed(self, existing_item, handle_response=True, on_delete=None):
# This will exit from the module on its own unless handle_response is False.
# If handle_response is True and the method successfully deletes an item and on_delete param is defined,
# the on_delete parameter will be called as a method pasing in this object and the json from the response
# If you pass handle_response=False, it will return one of two things:
# 1. None if the existing_item is not defined (so no delete needs to happen)
# 2. The response from Tower from calling the delete on the endpont. It's up to you to process the response and exit from the module
# Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False
if existing_item:
# If we have an item, we can try to delete it
try:
item_url = existing_item['url']
item_type = existing_item['type']
item_id = existing_item['id']
except KeyError as ke:
self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke))
if 'name' in existing_item:
item_name = existing_item['name']
elif 'username' in existing_item:
item_name = existing_item['username']
else:
self.fail_json(msg="Unable to process delete of {0} due to missing name".format(item_type))
response = self.delete_endpoint(item_url)
if not handle_response:
return response
elif response['status_code'] in [202, 204]:
if on_delete:
on_delete(self, response['json'])
self.json_output['changed'] = True
self.json_output['id'] = item_id
self.exit_json(**self.json_output)
else:
if 'json' in response and '__all__' in response['json']:
self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0]))
elif 'json' in response:
# This is from a project delete (if there is an active job against it)
if 'error' in response['json']:
self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']['error']))
else:
self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['json']))
else:
self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code']))
else:
if not handle_response:
return None
else:
self.exit_json(**self.json_output)
def create_if_needed(self, existing_item, new_item, endpoint, handle_response=True, on_create=None, item_type='unknown'):
#
# This will exit from the module on its own unless handle_response is False.
# If handle_response is True and the method successfully creates an item and on_create param is defined,
# the on_create parameter will be called as a method pasing in this object and the json from the response
# If you pass handle_response=False it will return one of two things:
# 1. None if the existing_item is already defined (so no create needs to happen)
# 2. The response from Tower from calling the patch on the endpont. It's up to you to process the response and exit from the module
# Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False
#
if not endpoint:
self.fail_json(msg="Unable to create new {0} due to missing endpoint".format(item_type))
if existing_item:
try:
existing_item['url']
except KeyError as ke:
self.fail_json(msg="Unable to process delete of item due to missing data {0}".format(ke))
if not handle_response:
return None
else:
self.exit_json(**self.json_output)
else:
# If we don't have an exisitng_item, we can try to create it
# We have to rely on item_type being passed in since we don't have an existing item that declares its type
# We will pull the item_name out from the new_item, if it exists
item_name = new_item.get('name', 'unknown')
response = self.post_endpoint(endpoint, **{'data': new_item})
if not handle_response:
return response
elif response['status_code'] == 201:
self.json_output['name'] = 'unknown'
if 'name' in response['json']:
self.json_output['name'] = response['json']['name']
elif 'username' in response['json']:
# User objects return username instead of name
self.json_output['name'] = response['json']['username']
self.json_output['id'] = response['json']['id']
self.json_output['changed'] = True
if on_create is None:
self.exit_json(**self.json_output)
else:
on_create(self, response['json'])
else:
if 'json' in response and '__all__' in response['json']:
self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json']['__all__'][0]))
elif 'json' in response:
self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['json']))
else:
self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']))
def update_if_needed(self, existing_item, new_item, handle_response=True, on_update=None):
# This will exit from the module on its own unless handle_response is False.
# If handle_response is True and the method successfully updates an item and on_update param is defined,
# the on_update parameter will be called as a method pasing in this object and the json from the response
# If you pass handle_response=False it will return one of three things:
# 1. None if the existing_item does not need to be updated
# 2. The response from Tower from patching to the endpoint. It's up to you to process the response and exit from the module.
# 3. An ItemNotDefined exception, if the existing_item does not exist
# Note: common error codes from the Tower API can cause the module to fail even if handle_response is set to False
if existing_item:
# If we have an item, we can see if it needs an update
try:
item_url = existing_item['url']
item_name = existing_item['name']
item_type = existing_item['url']
item_id = existing_item['id']
except KeyError as ke:
self.fail_json(msg="Unable to process update of item due to missing data {0}".format(ke))
needs_update = False
for field in new_item:
existing_field = existing_item.get(field, None)
new_field = new_item.get(field, None)
# If the two items don't match and we are not comparing '' to None
if existing_field != new_field and not (existing_field in (None, '') and new_field == ''):
# Something doesn't match so let's update it
needs_update = True
break
if needs_update:
response = self.patch_endpoint(item_url, **{'data': new_item})
if not handle_response:
return response
elif response['status_code'] == 200:
self.json_output['changed'] = True
self.json_output['id'] = item_id
if on_update is None:
self.exit_json(**self.json_output)
else:
on_update(self, response['json'])
elif 'json' in response and '__all__' in response['json']:
self.fail_json(msg=response['json']['__all__'])
else:
self.fail_json(**{'msg': "Unable to update {0} {1}, see response".format(item_type, item_name), 'response': response})
else:
if not handle_response:
return None
# Since we made it here, we don't need to update, status ok
self.json_output['changed'] = False
self.json_output['id'] = item_id
self.exit_json(**self.json_output)
else:
if handle_response:
self.fail_json(msg="The exstiing item is not defined and thus cannot be updated")
else:
raise ItemNotDefined("Not given an existing item to update")
def create_or_update_if_needed(self, existing_item, new_item, endpoint=None, handle_response=True, item_type='unknown', on_create=None, on_update=None):
if existing_item:
return self.update_if_needed(existing_item, new_item, handle_response=handle_response, on_update=on_update)
else:
return self.create_if_needed(existing_item, new_item, endpoint, handle_response=handle_response, on_create=on_create, item_type=item_type)
def logout(self):
if self.oauth_token_id is not None and self.username and self.password:
# Attempt to delete our current token from /api/v2/tokens/
# Post to the tokens endpoint with baisc auth to try and get a token
api_token_url = (self.url._replace(path='/api/v2/tokens/{0}/'.format(self.oauth_token_id))).geturl()
try:
self.session.open(
'DELETE',
api_token_url,
validate_certs=self.verify_ssl,
follow_redirects=True,
force_basic_auth=True,
url_username=self.username,
url_password=self.password
)
self.oauth_token_id = None
self.authenticated = False
except(Exception) as e:
# Sanity check: Did the server send back some kind of internal error?
self.warn('Failed to release tower token {0}: {1}'.format(self.oauth_token_id, e))
def fail_json(self, **kwargs):
# Try to log out if we are authenticated
self.logout()
super(TowerModule, self).fail_json(**kwargs)
def exit_json(self, **kwargs):
# Try to log out if we are authenticated
self.logout()
super(TowerModule, self).exit_json(**kwargs)
def is_job_done(self, job_status):
if job_status in ['new', 'pending', 'waiting', 'running']:
return False
else:
return True

View File

@ -160,6 +160,10 @@ options:
choices: ["present", "absent"]
default: "present"
type: str
requirements:
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
'''
@ -278,7 +282,7 @@ def main():
name=dict(required=True),
user=dict(),
team=dict(),
kind=dict(choices=KIND_CHOICES.keys()),
kind=dict(choices=list(KIND_CHOICES.keys())),
credential_type=dict(),
inputs=dict(type='dict'),
host=dict(),

View File

@ -64,12 +64,12 @@ options:
default: "present"
choices: ["present", "absent"]
type: str
validate_certs:
tower_oauthtoken:
description:
- Tower option to avoid certificates check.
- The Tower OAuth token to use.
required: False
type: bool
aliases: [ tower_verify_ssl ]
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -93,19 +93,7 @@ EXAMPLES = '''
RETURN = ''' # '''
from ..module_utils.ansible_tower import (
TowerModule,
tower_auth_config,
tower_check_mode
)
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
KIND_CHOICES = {
'ssh': 'Machine',
@ -118,62 +106,51 @@ KIND_CHOICES = {
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
description=dict(required=False),
kind=dict(required=False, choices=KIND_CHOICES.keys()),
kind=dict(required=False, choices=list(KIND_CHOICES.keys())),
inputs=dict(type='dict', required=False),
injectors=dict(type='dict', required=False),
state=dict(choices=['present', 'absent'], default='present'),
)
module = TowerModule(
argument_spec=argument_spec,
supports_check_mode=False
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Extract our parameters
name = module.params.get('name')
new_name = None
kind = module.params.get('kind')
state = module.params.get('state')
json_output = {'credential_type': name, 'state': state}
# These will be passed into the create/updates
credential_type_params = {
'name': new_name if new_name else name,
'kind': kind,
'managed_by_tower': False,
}
if module.params.get('description'):
credential_type_params['description'] = module.params.get('description')
if module.params.get('inputs'):
credential_type_params['inputs'] = module.params.get('inputs')
if module.params.get('injectors'):
credential_type_params['injectors'] = module.params.get('injectors')
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
credential_type_res = tower_cli.get_resource('credential_type')
# Attempt to look up credential_type based on the provided name
credential_type = module.get_one('credential_types', **{
'data': {
'name': name,
}
})
params = {}
params['name'] = name
params['kind'] = kind
params['managed_by_tower'] = False
if module.params.get('description'):
params['description'] = module.params.get('description')
if module.params.get('inputs'):
params['inputs'] = module.params.get('inputs')
if module.params.get('injectors'):
params['injectors'] = module.params.get('injectors')
try:
if state == 'present':
params['create_on_missing'] = True
result = credential_type_res.modify(**params)
json_output['id'] = result['id']
elif state == 'absent':
params['fail_on_missing'] = False
result = credential_type_res.delete(**params)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(
msg='Failed to update credential type: {0}'.format(excinfo),
changed=False
)
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(credential_type)
elif state == 'present':
# If the state was present and we can let the module build or update the existing credential type, this will return on its own
module.create_or_update_if_needed(credential_type, credential_type_params, endpoint='credential_types', item_type='credential type')
if __name__ == '__main__':

View File

@ -28,6 +28,12 @@ options:
- The name to use for the group.
required: True
type: str
new_name:
description:
- A new name for this group (for renaming)
required: False
type: str
version_added: "3.7"
description:
description:
- The description to use for the group.
@ -39,57 +45,20 @@ options:
type: str
variables:
description:
- Variables to use for the group, use C(@) for a file.
type: str
credential:
description:
- Credential to use for the group.
type: str
source:
description:
- The source to use for this group.
choices: ["manual", "file", "ec2", "vmware", "gce", "azure", "azure_rm", "openstack", "satellite6" , "cloudforms", "custom"]
type: str
source_regions:
description:
- Regions for cloud provider.
type: str
source_vars:
description:
- Override variables from source with variables from this field.
type: str
instance_filters:
description:
- Comma-separated list of filter expressions for matching hosts.
type: str
group_by:
description:
- Limit groups automatically created from inventory source.
type: str
source_script:
description:
- Inventory script to be used when group type is C(custom).
type: str
overwrite:
description:
- Delete child groups and hosts not found in source.
type: bool
default: 'no'
overwrite_vars:
description:
- Override vars in child groups and hosts with those from external source.
type: bool
update_on_launch:
description:
- Refresh inventory data from its source each time a job is run.
type: bool
default: 'no'
- Variables to use for the group.
type: dict
state:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
type: str
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -104,86 +73,59 @@ EXAMPLES = '''
tower_config_file: "~/tower_cli.cfg"
'''
import os
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
import json
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
description=dict(),
new_name=dict(required=False),
description=dict(required=False),
inventory=dict(required=True),
variables=dict(),
credential=dict(),
source=dict(choices=["manual", "file", "ec2", "vmware",
"gce", "azure", "azure_rm", "openstack",
"satellite6", "cloudforms", "custom"]),
source_regions=dict(),
source_vars=dict(),
instance_filters=dict(),
group_by=dict(),
source_script=dict(),
overwrite=dict(type='bool'),
overwrite_vars=dict(type='bool'),
update_on_launch=dict(type='bool'),
variables=dict(type='dict', required=False),
state=dict(choices=['present', 'absent'], default='present'),
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Extract our parameters
name = module.params.get('name')
new_name = module.params.get('new_name')
inventory = module.params.get('inventory')
credential = module.params.get('credential')
description = module.params.get('description')
state = module.params.pop('state')
variables = module.params.get('variables')
if variables:
if variables.startswith('@'):
filename = os.path.expanduser(variables[1:])
with open(filename, 'r') as f:
variables = f.read()
json_output = {'group': name, 'state': state}
# Attempt to look up the related items the user specified (these will fail the module if not found)
inventory_id = module.resolve_name_to_id('inventories', inventory)
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
group = tower_cli.get_resource('group')
try:
params = module.params.copy()
params['create_on_missing'] = True
params['variables'] = variables
# Attempt to look up the object based on the provided name and inventory ID
group = module.get_one('groups', **{
'data': {
'name': name,
'inventory': inventory_id
}
})
inv_res = tower_cli.get_resource('inventory')
inv = inv_res.get(name=inventory)
params['inventory'] = inv['id']
# Create the data that gets sent for create and update
group_fields = {
'name': new_name if new_name else name,
'inventory': inventory_id,
}
if description is not None:
group_fields['description'] = description
if variables is not None:
group_fields['variables'] = json.dumps(variables)
if credential:
cred_res = tower_cli.get_resource('credential')
cred = cred_res.get(name=credential)
params['credential'] = cred['id']
if state == 'present':
result = group.modify(**params)
json_output['id'] = result['id']
elif state == 'absent':
result = group.delete(**params)
except (exc.NotFound) as excinfo:
module.fail_json(msg='Failed to update the group, inventory not found: {0}'.format(excinfo), changed=False)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update the group: {0}'.format(excinfo), changed=False)
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(group)
elif state == 'present':
# If the state was present we can let the module build or update the existing group, this will return on its own
module.create_or_update_if_needed(group, group_fields, endpoint='groups', item_type='group')
if __name__ == '__main__':

View File

@ -28,6 +28,12 @@ options:
- The name to use for the host.
required: True
type: str
new_name:
description:
- To use when changing a hosts's name.
required: False
type: str
version_added: "3.7"
description:
description:
- The description to use for the host.
@ -44,14 +50,20 @@ options:
default: 'yes'
variables:
description:
- Variables to use for the host. Use C(@) for a file.
type: str
- Variables to use for the host.
type: dict
state:
description:
- Desired state of the resource.
choices: ["present", "absent"]
default: "present"
type: str
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -69,67 +81,62 @@ EXAMPLES = '''
'''
import os
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
import json
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
description=dict(),
new_name=dict(required=False),
description=dict(required=False),
inventory=dict(required=True),
enabled=dict(type='bool', default=True),
variables=dict(),
variables=dict(type='dict', required=False),
state=dict(choices=['present', 'absent'], default='present'),
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Extract our parameters
name = module.params.get('name')
new_name = module.params.get('new_name')
description = module.params.get('description')
inventory = module.params.get('inventory')
enabled = module.params.get('enabled')
state = module.params.get('state')
variables = module.params.get('variables')
if variables:
if variables.startswith('@'):
filename = os.path.expanduser(variables[1:])
with open(filename, 'r') as f:
variables = f.read()
json_output = {'host': name, 'state': state}
# Attempt to look up the related items the user specified (these will fail the module if not found)
inventory_id = module.resolve_name_to_id('inventories', inventory)
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
host = tower_cli.get_resource('host')
# Attempt to look up host based on the provided name and inventory ID
host = module.get_one('hosts', **{
'data': {
'name': name,
'inventory': inventory_id
}
})
try:
inv_res = tower_cli.get_resource('inventory')
inv = inv_res.get(name=inventory)
# Create the data that gets sent for create and update
host_fields = {
'name': new_name if new_name else name,
'inventory': inventory_id,
'enabled': enabled,
}
if description is not None:
host_fields['description'] = description
if variables is not None:
host_fields['variables'] = json.dumps(variables)
if state == 'present':
result = host.modify(name=name, inventory=inv['id'], enabled=enabled,
variables=variables, description=description, create_on_missing=True)
json_output['id'] = result['id']
elif state == 'absent':
result = host.delete(name=name, inventory=inv['id'])
except (exc.NotFound) as excinfo:
module.fail_json(msg='Failed to update host, inventory not found: {0}'.format(excinfo), changed=False)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update host: {0}'.format(excinfo), changed=False)
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(host)
elif state == 'present':
# If the state was present and we can let the module build or update the existing host, this will return on its own
module.create_or_update_if_needed(host, host_fields, endpoint='hosts', item_type='host')
if __name__ == '__main__':

View File

@ -39,8 +39,9 @@ options:
type: str
variables:
description:
- Inventory variables. Use C(@) to get from file.
type: str
- Inventory variables.
required: False
type: dict
kind:
description:
- The kind field. Cannot be modified after created.
@ -59,6 +60,12 @@ options:
default: "present"
choices: ["present", "absent"]
type: str
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -74,30 +81,26 @@ EXAMPLES = '''
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
import json
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
description=dict(),
description=dict(required=False),
organization=dict(required=True),
variables=dict(),
variables=dict(type='dict', required=False),
kind=dict(choices=['', 'smart'], default=''),
host_filter=dict(),
state=dict(choices=['present', 'absent'], default='present'),
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Extract our parameters
name = module.params.get('name')
description = module.params.get('description')
organization = module.params.get('organization')
@ -106,31 +109,39 @@ def main():
kind = module.params.get('kind')
host_filter = module.params.get('host_filter')
json_output = {'inventory': name, 'state': state}
# Attempt to look up the related items the user specified (these will fail the module if not found)
org_id = module.resolve_name_to_id('organizations', organization)
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
inventory = tower_cli.get_resource('inventory')
# Attempt to look up inventory based on the provided name and org ID
inventory = module.get_one('inventories', **{
'data': {
'name': name,
'organization': org_id
}
})
try:
org_res = tower_cli.get_resource('organization')
org = org_res.get(name=organization)
# Create the data that gets sent for create and update
inventory_fields = {
'name': name,
'organization': org_id,
'kind': kind,
'host_filter': host_filter,
}
if description is not None:
inventory_fields['description'] = description
if variables is not None:
inventory_fields['variables'] = json.dumps(variables)
if state == 'present':
result = inventory.modify(name=name, organization=org['id'], variables=variables,
description=description, kind=kind, host_filter=host_filter,
create_on_missing=True)
json_output['id'] = result['id']
elif state == 'absent':
result = inventory.delete(name=name, organization=org['id'])
except (exc.NotFound) as excinfo:
module.fail_json(msg='Failed to update inventory, organization not found: {0}'.format(excinfo), changed=False)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update inventory: {0}'.format(excinfo), changed=False)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(inventory)
elif state == 'present':
# We need to perform a check to make sure you are not trying to convert a regular inventory into a smart one.
if inventory and inventory['kind'] == '' and inventory_fields['kind'] == 'smart':
module.fail_json(msg='You cannot turn a regular inventory into a "smart" inventory.')
json_output['changed'] = result['changed']
module.exit_json(**json_output)
# If the state was present and we can let the module build or update the existing inventory, this will return on its own
module.create_or_update_if_needed(inventory, inventory_fields, endpoint='inventories', item_type='inventory')
if __name__ == '__main__':

View File

@ -8,9 +8,9 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
ANSIBLE_METADATA = {'status': ['preview'],
'supported_by': 'community',
'metadata_version': '1.1'}
ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}
DOCUMENTATION = '''
@ -20,7 +20,7 @@ author: "Adrien Fleury (@fleu42)"
version_added: "2.7"
short_description: create, update, or destroy Ansible Tower inventory source.
description:
- Create, update, or destroy Ansible Tower inventories source. See
- Create, update, or destroy Ansible Tower inventory source. See
U(https://www.ansible.com/tower) for an overview.
options:
name:
@ -28,325 +28,222 @@ options:
- The name to use for the inventory source.
required: True
type: str
new_name:
description:
- A new name for this assets (will rename the asset)
required: False
type: str
version_added: "3.7"
description:
description:
- The description to use for the inventory source.
type: str
inventory:
description:
- The inventory the source is linked to.
- Inventory the group should be made a member of.
required: True
type: str
organization:
description:
- Organization the inventory belongs to.
type: str
source:
description:
- Types of inventory source.
choices:
- file
- scm
- ec2
- gce
- azure
- azure_rm
- vmware
- satellite6
- cloudforms
- openstack
- rhv
- tower
- custom
required: True
- The source to use for this group.
choices: [ "scm", "ec2", "gce", "azure_rm", "vmware", "satellite6", "cloudforms", "openstack", "rhv", "tower", "custom" ]
type: str
credential:
required: False
source_path:
description:
- Credential to use to retrieve the inventory from.
- For an SCM based inventory source, the source path points to the file within the repo to use as an inventory.
type: str
source_script:
description:
- Inventory script to be used when group type is C(custom).
type: str
required: False
source_vars:
description:
- >-
The source_vars allow to Override variables found in the source config
file. For example with Openstack, specifying *private: false* would
change the output of the openstack.py script. It has to be YAML or
JSON.
- The variables or environment fields to apply to this source type.
type: dict
credential:
description:
- Credential to use for the source.
type: str
source_regions:
description:
- Regions for cloud provider.
type: str
instance_filters:
description:
- Comma-separated list of filter expressions for matching hosts.
type: str
group_by:
description:
- Limit groups automatically created from inventory source.
type: str
overwrite:
description:
- Delete child groups and hosts not found in source.
type: bool
default: 'no'
overwrite_vars:
description:
- Override vars in child groups and hosts with those from external source.
type: bool
custom_virtualenv:
version_added: "2.9"
description:
- Local absolute file path containing a custom Python virtualenv to use.
type: str
required: False
default: ''
timeout:
description: The amount of time (in seconds) to run before the task is canceled.
type: int
verbosity:
description: The verbosity level to run this inventory source under.
type: int
choices: [ 0, 1, 2 ]
update_on_launch:
description:
- Number in seconds after which the Tower API methods will time out.
- Refresh inventory data from its source each time a job is run.
type: bool
default: 'no'
update_cache_timeout:
description:
- Time in seconds to consider an inventory sync to be current.
type: int
source_project:
description:
- Use a *project* as a source for the *inventory*.
type: str
source_path:
description:
- Path to the file to use as a source in the selected *project*.
- Project to use as source with scm option
type: str
update_on_project_update:
description:
- >-
That parameter will sync the inventory when the project is synced. It
can only be used with a SCM source.
description: Update this source when the related project updates if source is C(scm)
type: bool
source_regions:
description:
- >-
List of regions for your cloud provider. You can include multiple all
regions. Only Hosts associated with the selected regions will be
updated. Refer to Ansible Tower documentation for more detail.
type: str
instance_filters:
description:
- >-
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.
type: str
group_by:
description:
- >-
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.
type: str
source_script:
description:
- >-
The source custom script to use to build the inventory. It needs to
exist.
type: str
overwrite:
description:
- >-
If set, any hosts and groups that were previously present on the
external source but are now removed will be removed from the Tower
inventory. Hosts and groups that were not managed by the inventory
source will be promoted to the next manually created group or if
there is no manually created group to promote them into, they will be
left in the "all" default group for the inventory. When not checked,
local child hosts and groups not found on the external source will
remain untouched by the inventory update process.
type: bool
overwrite_vars:
description:
- >-
If set, all variables for child groups and hosts will be removed
and replaced by those found on the external source. When not checked,
a merge will be performed, combining local variables with those found
on the external source.
type: bool
update_on_launch:
description:
- >-
Each time a job runs using this inventory, refresh the inventory from
the selected source before executing job tasks.
type: bool
update_cache_timeout:
description:
- >-
Time in seconds to consider an inventory sync to be current. During
job runs and callbacks the task system will evaluate the timestamp of
the latest sync. If it is older than Cache Timeout, it is not
considered current, and a new inventory sync will be performed.
type: int
state:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
type: str
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
EXAMPLES = '''
- name: Add tower inventory source
- name: Add an inventory source
tower_inventory_source:
name: Inventory source
description: My Inventory source
inventory: My inventory
organization: My organization
credential: Devstack_credential
source: openstack
update_on_launch: true
overwrite: true
source_vars: '{ private: false }'
state: present
validate_certs: false
name: "source-inventory"
description: Source for inventory
inventory: previously-created-inventory
credential: previously-created-credential
overwrite: True
update_on_launch: True
source_vars:
private: false
'''
RETURN = ''' # '''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
SOURCE_CHOICES = {
'file': 'Directory or Script',
'scm': 'Sourced from a Project',
'ec2': 'Amazon EC2',
'gce': 'Google Compute Engine',
'azure': 'Microsoft Azure',
'azure_rm': 'Microsoft Azure Resource Manager',
'vmware': 'VMware vCenter',
'satellite6': 'Red Hat Satellite 6',
'cloudforms': 'Red Hat CloudForms',
'openstack': 'OpenStack',
'rhv': 'Red Hat Virtualization',
'tower': 'Ansible Tower',
'custom': 'Custom Script',
}
from ..module_utils.tower_api import TowerModule
from json import dumps
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
new_name=dict(type='str'),
description=dict(required=False),
inventory=dict(required=True),
source=dict(required=True,
choices=SOURCE_CHOICES.keys()),
credential=dict(required=False),
source_vars=dict(required=False),
timeout=dict(type='int', required=False),
source_project=dict(required=False),
source_path=dict(required=False),
update_on_project_update=dict(type='bool', required=False),
source_regions=dict(required=False),
instance_filters=dict(required=False),
group_by=dict(required=False),
#
# How do we handle manual and file? Tower does not seem to be able to activate them
#
source=dict(choices=["scm", "ec2", "gce",
"azure_rm", "vmware", "satellite6", "cloudforms",
"openstack", "rhv", "tower", "custom"], required=False),
source_path=dict(),
source_script=dict(required=False),
overwrite=dict(type='bool', required=False),
overwrite_vars=dict(type='bool', required=False),
custom_virtualenv=dict(type='str', required=False),
update_on_launch=dict(type='bool', required=False),
update_cache_timeout=dict(type='int', required=False),
organization=dict(type='str'),
source_vars=dict(type='dict'),
credential=dict(),
source_regions=dict(),
instance_filters=dict(),
group_by=dict(),
overwrite=dict(type='bool'),
overwrite_vars=dict(type='bool'),
custom_virtualenv=dict(type='str', default=''),
timeout=dict(type='int'),
verbosity=dict(type='int', choices=[0, 1, 2]),
update_on_launch=dict(type='bool'),
update_cache_timeout=dict(type='int'),
source_project=dict(type='str'),
update_on_project_update=dict(type='bool'),
state=dict(choices=['present', 'absent'], default='present'),
)
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec)
# Extract our parameters
name = module.params.get('name')
new_name = module.params.get('new_name')
inventory = module.params.get('inventory')
source = module.params.get('source')
source_script = module.params.get('source_script')
credential = module.params.get('credential')
source_project = module.params.get('source_project')
state = module.params.get('state')
organization = module.params.get('organization')
json_output = {'inventory_source': name, 'state': state}
# Attempt to look up inventory source based on the provided name and inventory ID
inventory_id = module.resolve_name_to_id('inventories', inventory)
inventory_source = module.get_one('inventory_sources', **{
'data': {
'name': name,
'inventory': inventory_id,
}
})
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
inventory_source = tower_cli.get_resource('inventory_source')
try:
params = {}
params['name'] = name
params['source'] = source
# Create the data that gets sent for create and update
inventory_source_fields = {
'name': new_name if new_name else name,
'inventory': inventory_id,
}
if module.params.get('description'):
params['description'] = module.params.get('description')
# Attempt to look up the related items the user specified (these will fail the module if not found)
if credential is not None:
inventory_source_fields['credential'] = module.resolve_name_to_id('credentials', credential)
if source_project is not None:
inventory_source_fields['source_project'] = module.resolve_name_to_id('projects', source_project)
if source_script is not None:
inventory_source_fields['source_script'] = module.resolve_name_to_id('inventory_scripts', source_script)
if organization:
try:
org_res = tower_cli.get_resource('organization')
org = org_res.get(name=organization)
except (exc.NotFound) as excinfo:
module.fail_json(
msg='Failed to get organization,'
'organization not found: {0}'.format(excinfo),
changed=False
)
org_id = org['id']
else:
org_id = None # interpreted as not provided
OPTIONAL_VARS = (
'description', 'source', 'source_path', 'source_vars',
'source_regions', 'instance_filters', 'group_by',
'overwrite', 'overwrite_vars', 'custom_virtualenv',
'timeout', 'verbosity', 'update_on_launch', 'update_cache_timeout',
'update_on_project_update'
)
if module.params.get('credential'):
credential_res = tower_cli.get_resource('credential')
try:
credential = credential_res.get(
name=module.params.get('credential'), organization=org_id)
params['credential'] = credential['id']
except (exc.NotFound) as excinfo:
module.fail_json(
msg='Failed to update credential source,'
'credential not found: {0}'.format(excinfo),
changed=False
)
# Layer in all remaining optional information
for field_name in OPTIONAL_VARS:
field_val = module.params.get(field_name)
if field_val:
inventory_source_fields[field_name] = field_val
if module.params.get('source_project'):
source_project_res = tower_cli.get_resource('project')
try:
source_project = source_project_res.get(
name=module.params.get('source_project'), organization=org_id)
params['source_project'] = source_project['id']
except (exc.NotFound) as excinfo:
module.fail_json(
msg='Failed to update source project,'
'project not found: {0}'.format(excinfo),
changed=False
)
# Attempt to JSON encode source vars
if inventory_source_fields.get('source_vars', None):
inventory_source_fields['source_vars'] = dumps(inventory_source_fields['source_vars'])
if module.params.get('source_script'):
source_script_res = tower_cli.get_resource('inventory_script')
try:
script = source_script_res.get(
name=module.params.get('source_script'), organization=org_id)
params['source_script'] = script['id']
except (exc.NotFound) as excinfo:
module.fail_json(
msg='Failed to update source script,'
'script not found: {0}'.format(excinfo),
changed=False
)
# Sanity check on arguments
if state == 'present' and not inventory_source and not inventory_source_fields['source']:
module.fail_json(msg="If creating a new inventory source, the source param must be present")
try:
inventory_res = tower_cli.get_resource('inventory')
params['inventory'] = inventory_res.get(name=inventory, organization=org_id)['id']
except (exc.NotFound) as excinfo:
module.fail_json(
msg='Failed to update inventory source, '
'inventory not found: {0}'.format(excinfo),
changed=False
)
for key in ('source_vars', 'custom_virtualenv', 'timeout', 'source_path',
'update_on_project_update', 'source_regions',
'instance_filters', 'group_by', 'overwrite',
'overwrite_vars', 'update_on_launch',
'update_cache_timeout'):
if module.params.get(key) is not None:
params[key] = module.params.get(key)
if state == 'present':
params['create_on_missing'] = True
result = inventory_source.modify(**params)
json_output['id'] = result['id']
elif state == 'absent':
params['fail_on_missing'] = False
result = inventory_source.delete(**params)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update inventory source: \
{0}'.format(excinfo), changed=False)
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(inventory_source)
elif state == 'present':
# If the state was present we can let the module build or update the existing inventory_source, this will return on its own
module.create_or_update_if_needed(inventory_source, inventory_source_fields, endpoint='inventory_sources', item_type='inventory source')
if __name__ == '__main__':

View File

@ -30,9 +30,15 @@ options:
type: int
fail_if_not_running:
description:
- Fail loudly if the I(job_id) does not reference a running job.
- Fail loudly if the I(job_id) can not be canceled
default: False
type: bool
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -48,54 +54,55 @@ id:
returned: success
type: int
sample: 94
status:
description: status of the cancel request
returned: success
type: str
sample: canceled
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
job_id=dict(type='int', required=True),
fail_if_not_running=dict(type='bool', default=False),
)
# Create a module for ourselves
module = TowerModule(
argument_spec=argument_spec,
supports_check_mode=True,
)
# Extract our parameters
job_id = module.params.get('job_id')
json_output = {}
fail_if_not_running = module.params.get('fail_if_not_running')
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
job = tower_cli.get_resource('job')
params = module.params.copy()
# Attempt to look up the job based on the provided name
job = module.get_one('jobs', **{
'data': {
'id': job_id,
}
})
try:
result = job.cancel(job_id, **params)
json_output['id'] = job_id
except (exc.ConnectionError, exc.BadRequest, exc.TowerCLIError, exc.AuthError) as excinfo:
module.fail_json(msg='Unable to cancel job_id/{0}: {1}'.format(job_id, excinfo), changed=False)
if job is None:
module.fail_json(msg="Unable to find job with id {0}".format(job_id))
json_output['changed'] = result['changed']
json_output['status'] = result['status']
module.exit_json(**json_output)
cancel_page = module.get_endpoint(job['related']['cancel'])
if 'json' not in cancel_page or 'can_cancel' not in cancel_page['json']:
module.fail_json(msg="Failed to cancel job, got unexpected response from tower", **{'response': cancel_page})
if not cancel_page['json']['can_cancel']:
if fail_if_not_running:
module.fail_json(msg="Job is not running")
else:
module.exit_json(**{'changed': False})
results = module.post_endpoint(job['related']['cancel'], **{'data': {}})
if results['status_code'] != 202:
module.fail_json(msg="Failed to cancel job, see response for details", **{'response': results})
module.exit_json(**{'changed': True})
if __name__ == '__main__':

View File

@ -23,11 +23,12 @@ description:
- Launch an Ansible Tower jobs. See
U(https://www.ansible.com/tower) for an overview.
options:
job_template:
name:
description:
- Name of the job template to use.
required: True
type: str
aliases: ['job_template']
job_type:
description:
- Job_type to use for the job, only used if prompt for job_type is set.
@ -37,13 +38,15 @@ options:
description:
- Inventory to use for the job, only used if prompt for inventory is set.
type: str
credential:
credentials:
description:
- Credential to use for job, only used if prompt for credential is set.
type: str
type: list
aliases: ['credential']
elements: str
extra_vars:
description:
- extra_vars to use for the Job Template. Prepend C(@) if a file.
- extra_vars to use for the Job Template.
- ask_extra_vars needs to be set to True via tower_job_template module
when creating the Job Template.
type: dict
@ -55,21 +58,50 @@ options:
description:
- Specific tags to use for from playbook.
type: list
elements: str
scm_branch:
description:
- A specific of the SCM project to run the template on.
- This is only applicable if your project allows for branch override.
type: str
version_added: "3.7"
skip_tags:
description:
- Specific tags to skip from the playbook.
type: list
elements: str
version_added: "3.7"
verbosity:
description:
- Verbosity level for this job run
type: int
choices: [ 0, 1, 2, 3, 4, 5 ]
version_added: "3.7"
diff_mode:
description:
- Show the changes made by Ansible tasks where supported
type: bool
version_added: "3.7"
credential_passwords:
description:
- Passwords for credentials which are set to prompt on launch
type: dict
version_added: "3.7"
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
EXAMPLES = '''
# Launch a job template
- name: Launch a job
tower_job_launch:
job_template: "My Job Template"
register: job
- name: Wait for job max 120s
tower_job_wait:
job_id: "{{ job.id }}"
timeout: 120
- name: Launch a job template with extra_vars on remote Tower instance
tower_job_launch:
job_template: "My Job Template"
@ -79,7 +111,6 @@ EXAMPLES = '''
var3: "My Third Variable"
job_type: run
# Launch job template with inventory and credential for prompt on launch
- name: Launch a job with inventory and credential
tower_job_launch:
job_template: "My Job Template"
@ -105,91 +136,103 @@ status:
sample: pending
'''
import json
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
def update_fields(module, p):
params = p.copy()
params_update = {}
job_template = params.get('job_template')
extra_vars = params.get('extra_vars')
try:
job_template_to_launch = tower_cli.get_resource('job_template').get(name=job_template)
except (exc.NotFound) as excinfo:
module.fail_json(msg='Unable to launch job, job_template/{0} was not found: {1}'.format(job_template, excinfo), changed=False)
ask_extra_vars = job_template_to_launch['ask_variables_on_launch']
survey_enabled = job_template_to_launch['survey_enabled']
if extra_vars and (ask_extra_vars or survey_enabled):
params_update['extra_vars'] = [json.dumps(extra_vars)]
elif extra_vars:
module.fail_json(msg="extra_vars is set on launch but the Job Template does not have ask_extra_vars or survey_enabled set to True.")
params.update(params_update)
return params
from ..module_utils.tower_api import TowerModule
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
job_template=dict(required=True, type='str'),
job_type=dict(choices=['run', 'check']),
name=dict(type='str', required=True, aliases=['job_template']),
job_type=dict(type='str', choices=['run', 'check']),
inventory=dict(type='str', default=None),
credential=dict(type='str', default=None),
# Credentials will be a str instead of a list for backwards compatability
credentials=dict(type='list', default=None, aliases=['credential'], elements='str'),
limit=dict(),
tags=dict(type='list'),
tags=dict(type='list', elements='str'),
extra_vars=dict(type='dict', required=False),
scm_branch=dict(type='str', required=False),
skip_tags=dict(type='list', required=False, elements='str'),
verbosity=dict(type='int', required=False, choices=[0, 1, 2, 3, 4, 5]),
diff_mode=dict(type='bool', required=False),
credential_passwords=dict(type='dict', required=False),
)
module = TowerModule(
argument_spec=argument_spec,
supports_check_mode=True
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
json_output = {}
tags = module.params.get('tags')
optional_args = {}
# Extract our parameters
name = module.params.get('name')
optional_args['job_type'] = module.params.get('job_type')
inventory = module.params.get('inventory')
credentials = module.params.get('credentials')
optional_args['limit'] = module.params.get('limit')
optional_args['tags'] = module.params.get('tags')
optional_args['extra_vars'] = module.params.get('extra_vars')
optional_args['scm_branch'] = module.params.get('scm_branch')
optional_args['skip_tags'] = module.params.get('skip_tags')
optional_args['verbosity'] = module.params.get('verbosity')
optional_args['diff_mode'] = module.params.get('diff_mode')
optional_args['credential_passwords'] = module.params.get('credential_passwords')
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
try:
params = module.params.copy()
if isinstance(tags, list):
params['tags'] = ','.join(tags)
job = tower_cli.get_resource('job')
# Create a datastructure to pass into our job launch
post_data = {}
for key in optional_args.keys():
if optional_args[key]:
post_data[key] = optional_args[key]
params = update_fields(module, params)
# Attempt to look up the related items the user specified (these will fail the module if not found)
if inventory:
post_data['inventory'] = module.resolve_name_to_id('inventories', inventory)
lookup_fields = ('job_template', 'inventory', 'credential')
for field in lookup_fields:
try:
name = params.pop(field)
if name:
result = tower_cli.get_resource(field).get(name=name)
params[field] = result['id']
except exc.NotFound as excinfo:
module.fail_json(msg='Unable to launch job, {0}/{1} was not found: {2}'.format(field, name, excinfo), changed=False)
if credentials:
post_data['credentials'] = []
for credential in credentials:
post_data['credentials'].append(module.resolve_name_to_id('credentials', credential))
result = job.launch(no_input=True, **params)
json_output['id'] = result['id']
json_output['status'] = result['status']
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Unable to launch job: {0}'.format(excinfo), changed=False)
# Attempt to look up job_template based on the provided name
job_template = module.get_one('job_templates', **{
'data': {
'name': name,
}
})
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if job_template is None:
module.fail_json(msg="Unable to find job template by name {0}".format(name))
# The API will allow you to submit values to a jb launch that are not prompt on launch.
# Therefore, we will test to see if anything is set which is not prompt on launch and fail.
check_vars_to_prompts = {
'scm_branch': 'ask_scm_branch_on_launch',
'diff_mode': 'ask_diff_mode_on_launch',
'extra_vars': 'ask_variables_on_launch',
'limit': 'ask_limit_on_launch',
'tags': 'ask_tags_on_launch',
'skip_tags': 'ask_skip_tags_on_launch',
'job_type': 'ask_job_type_on_launch',
'verbosity': 'ask_verbosity_on_launch',
'inventory': 'ask_inventory_on_launch',
'credentials': 'ask_credential_on_launch',
}
param_errors = []
for variable_name in check_vars_to_prompts:
if module.params.get(variable_name) and not job_template[check_vars_to_prompts[variable_name]]:
param_errors.append("The field {0} was specified but the job template does not allow for it to be overridden".format(variable_name))
if len(param_errors) > 0:
module.fail_json(msg="Parameters specified which can not be passed into job template, see errors for details", **{'errors': param_errors})
# Launch the job
results = module.post_endpoint(job_template['related']['launch'], **{'data': post_data})
if results['status_code'] != 201:
module.fail_json(msg="Failed to launch job, see response for details", **{'response': results})
module.exit_json(**{
'changed': True,
'id': results['json']['id'],
'status': results['json']['status'],
})
if __name__ == '__main__':

View File

@ -41,6 +41,12 @@ options:
description:
- Query used to further filter the list of jobs. C({"foo":"bar"}) will be passed at C(?foo=bar)
type: dict
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -81,18 +87,11 @@ results:
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
status=dict(choices=['pending', 'waiting', 'running', 'error', 'failed', 'canceled', 'successful']),
page=dict(type='int'),
@ -100,31 +99,35 @@ def main():
query=dict(type='dict'),
)
# Create a module for ourselves
module = TowerModule(
argument_spec=argument_spec,
supports_check_mode=True
supports_check_mode=True,
mutually_exclusive=[
('page', 'all_pages'),
]
)
json_output = {}
# Extract our parameters
query = module.params.get('query')
status = module.params.get('status')
page = module.params.get('page')
all_pages = module.params.get('all_pages')
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
try:
job = tower_cli.get_resource('job')
params = {'status': status, 'page': page, 'all_pages': all_pages}
if query:
params['query'] = query.items()
json_output = job.list(**params)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to list jobs: {0}'.format(excinfo), changed=False)
job_search_data = {}
if page:
job_search_data['page'] = page
if status:
job_search_data['status'] = status
if query:
job_search_data.update(query)
if all_pages:
job_list = module.get_all_endpoint('jobs', **{'data': job_search_data})
else:
job_list = module.get_endpoint('jobs', **{'data': job_search_data})
module.exit_json(**json_output)
# Attempt to look up jobs based on the status
module.exit_json(**job_list['json'])
if __name__ == '__main__':

View File

@ -65,6 +65,7 @@ options:
version_added: 2.8
type: list
default: []
elements: str
vault_credential:
description:
- Name of the vault credential to use for the job template.
@ -219,7 +220,12 @@ options:
default: "present"
choices: ["present", "absent"]
type: str
requirements:
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
notes:
- JSON for survey_spec can be found in Tower API Documentation. See
U(https://docs.ansible.com/ansible-tower/latest/html/towerapi/api_ref.html#/Job_Templates/Job_Templates_job_templates_survey_spec_create)
@ -333,7 +339,7 @@ def main():
credential=dict(default=''),
vault_credential=dict(default=''),
custom_virtualenv=dict(type='str', required=False),
credentials=dict(type='list', default=[]),
credentials=dict(type='list', default=[], elements='str'),
forks=dict(type='int'),
limit=dict(default=''),
verbosity=dict(type='int', choices=[0, 1, 2, 3, 4], default=0),

View File

@ -42,6 +42,10 @@ options:
description:
- Maximum time in seconds to wait for a job to finish.
type: int
requirements:
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
'''

View File

@ -39,6 +39,10 @@ options:
default: "present"
choices: ["present", "absent"]
type: str
requirements:
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
'''

View File

@ -0,0 +1,91 @@
#!/usr/bin/python
# coding: utf-8 -*-
# (c) 2019, John Westcott IV <john.westcott.iv@redhat.com>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
ANSIBLE_METADATA = {'metadata_version': '1.1',
'status': ['preview'],
'supported_by': 'community'}
DOCUMENTATION = '''
---
module: tower_license
author: "John Westcott IV (@john-westcott-iv)"
version_added: "2.9"
short_description: Set the license for Ansible Tower
description:
- Get or Set Ansible Tower license. See
U(https://www.ansible.com/tower) for an overview.
options:
data:
description:
- The contents of the license file
required: True
type: dict
version_added: "3.7"
eula_accepted:
description:
- Whether or not the EULA is accepted.
required: True
type: bool
version_added: "3.7"
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
RETURN = ''' # '''
EXAMPLES = '''
- name: Set the license using a file
license:
data: "{{ lookup('file', '/tmp/my_tower.license') }}"
eula_accepted: True
'''
from ..module_utils.tower_api import TowerModule
def main():
module = TowerModule(
argument_spec=dict(
data=dict(type='dict', required=True),
eula_accepted=dict(type='bool', required=True),
),
supports_check_mode=True
)
json_output = {'changed': False}
if not module.params.get('eula_accepted'):
module.fail_json(msg='You must accept the EULA by passing in the param eula_accepted as True')
json_output['old_license'] = module.get_endpoint('settings/system/')['json']['LICENSE']
new_license = module.params.get('data')
if json_output['old_license'] != new_license:
json_output['changed'] = True
# Deal with check mode
if module.check_mode:
module.exit_json(**json_output)
# We need to add in the EULA
new_license['eula_accepted'] = True
module.post_endpoint('config', data=new_license)
module.exit_json(**json_output)
if __name__ == '__main__':
main()

View File

@ -64,6 +64,7 @@ options:
- The recipients email addresses. Required if I(notification_type=email).
required: False
type: list
elements: str
use_tls:
description:
- The TLS trigger. Required if I(notification_type=email).
@ -94,6 +95,7 @@ options:
- The destination Slack channels. Required if I(notification_type=slack).
required: False
type: list
elements: str
token:
description:
- The access token. Required if I(notification_type=slack), if I(notification_type=pagerduty) or if I(notification_type=hipchat).
@ -114,6 +116,7 @@ options:
- The destination phone numbers. Required if I(notification_type=twillio).
required: False
type: list
elements: str
account_sid:
description:
- The Twillio account SID. Required if I(notification_type=twillio).
@ -155,6 +158,7 @@ options:
- HipChat rooms to send the notification to. Required if I(notification_type=hipchat).
required: False
type: list
elements: str
notify:
description:
- The notify channel trigger. Required if I(notification_type=hipchat).
@ -185,12 +189,17 @@ options:
- The destination channels or users. Required if I(notification_type=irc).
required: False
type: list
elements: str
state:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
type: str
requirements:
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
'''
@ -315,17 +324,17 @@ def main():
notification_configuration=dict(required=False),
username=dict(required=False),
sender=dict(required=False),
recipients=dict(required=False, type='list'),
recipients=dict(required=False, type='list', elements='str'),
use_tls=dict(required=False, type='bool'),
host=dict(required=False),
use_ssl=dict(required=False, type='bool'),
password=dict(required=False, no_log=True),
port=dict(required=False, type='int'),
channels=dict(required=False, type='list'),
channels=dict(required=False, type='list', elements='str'),
token=dict(required=False, no_log=True),
account_token=dict(required=False, no_log=True),
from_number=dict(required=False),
to_numbers=dict(required=False, type='list'),
to_numbers=dict(required=False, type='list', elements='str'),
account_sid=dict(required=False),
subdomain=dict(required=False),
service_key=dict(required=False, no_log=True),
@ -333,13 +342,13 @@ def main():
message_from=dict(required=False),
api_url=dict(required=False),
color=dict(required=False, choices=['yellow', 'green', 'red', 'purple', 'gray', 'random']),
rooms=dict(required=False, type='list'),
rooms=dict(required=False, type='list', elements='str'),
notify=dict(required=False, type='bool'),
url=dict(required=False),
headers=dict(required=False, type='dict', default={}),
server=dict(required=False),
nickname=dict(required=False),
targets=dict(required=False, type='list'),
targets=dict(required=False, type='list', elements='str'),
state=dict(choices=['present', 'absent'], default='present'),
)

View File

@ -39,12 +39,25 @@ options:
type: str
required: False
default: ''
max_hosts:
description:
- The max hosts allowed in this organizations
default: "0"
type: int
required: False
version_added: "3.7"
state:
description:
- Desired state of the resource.
default: "present"
choices: ["present", "absent"]
type: str
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -66,49 +79,52 @@ EXAMPLES = '''
tower_config_file: "~/tower_cli.cfg"
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
description=dict(),
name=dict(type='str', required=True),
description=dict(type='str', required=False),
custom_virtualenv=dict(type='str', required=False),
state=dict(choices=['present', 'absent'], default='present'),
max_hosts=dict(type='int', required=False, default="0"),
state=dict(type='str', choices=['present', 'absent'], default='present', required=False),
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Extract our parameters
name = module.params.get('name')
description = module.params.get('description')
custom_virtualenv = module.params.get('custom_virtualenv')
max_hosts = module.params.get('max_hosts')
# instance_group_names = module.params.get('instance_groups')
state = module.params.get('state')
json_output = {'organization': name, 'state': state}
# Attempt to look up organization based on the provided name
organization = module.get_one('organizations', **{
'data': {
'name': name,
}
})
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
organization = tower_cli.get_resource('organization')
try:
if state == 'present':
result = organization.modify(name=name, description=description, custom_virtualenv=custom_virtualenv, create_on_missing=True)
json_output['id'] = result['id']
elif state == 'absent':
result = organization.delete(name=name)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update the organization: {0}'.format(excinfo), changed=False)
# Create the data that gets sent for create and update
org_fields = {'name': name}
if description is not None:
org_fields['description'] = description
if custom_virtualenv is not None:
org_fields['custom_virtualenv'] = custom_virtualenv
if max_hosts is not None:
org_fields['max_hosts'] = max_hosts
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(organization)
elif state == 'present':
# If the state was present and we can let the module build or update the existing organization, this will return on its own
module.create_or_update_if_needed(organization, org_fields, endpoint='organizations', item_type='organization')
if __name__ == '__main__':

View File

@ -35,7 +35,7 @@ options:
scm_type:
description:
- Type of SCM resource.
choices: ["manual", "git", "hg", "svn"]
choices: ["manual", "git", "hg", "svn", "insights"]
default: "manual"
type: str
scm_url:
@ -50,6 +50,13 @@ options:
description:
- The branch to use for the SCM resource.
type: str
default: ''
scm_refspec:
description:
- The refspec to use for the SCM resource.
type: str
default: ''
version_added: "3.7"
scm_credential:
description:
- Name of the credential to use with this SCM resource.
@ -74,8 +81,13 @@ options:
description:
- Cache Timeout to cache prior project syncs for a certain number of seconds.
Only valid if scm_update_on_launch is to True, otherwise ignored.
default: 0
type: int
default: 0
scm_allow_override:
description:
- Allow changing the SCM branch or revision in a job template that uses this project.
type: bool
version_added: "3.7"
job_timeout:
version_added: "2.8"
description:
@ -91,8 +103,9 @@ options:
default: ''
organization:
description:
- Primary key of organization for project.
- Name of organization for project.
type: str
required: True
state:
description:
- Desired state of the resource.
@ -105,9 +118,14 @@ options:
before returning
- Can assure playbook files are populated so that job templates that rely
on the project may be successfully created
type: bool
default: True
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -133,107 +151,129 @@ EXAMPLES = '''
tower_config_file: "~/tower_cli.cfg"
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
import time
try:
import tower_cli
import tower_cli.exceptions as exc
from ..module_utils.tower_api import TowerModule
from tower_cli.conf import settings
except ImportError:
pass
def wait_for_project_update(module, last_request):
# The current running job for the udpate is in last_request['summary_fields']['current_update']['id']
if 'current_update' in last_request['summary_fields']:
running = True
while running:
result = module.get_endpoint('/project_updates/{0}/'.format(last_request['summary_fields']['current_update']['id']))['json']
if module.is_job_done(result['status']):
time.sleep(1)
running = False
if result['status'] != 'successful':
module.fail_json(msg="Project update failed")
module.exit_json(**module.json_output)
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
description=dict(),
organization=dict(),
scm_type=dict(choices=['manual', 'git', 'hg', 'svn'], default='manual'),
scm_url=dict(),
scm_branch=dict(),
scm_credential=dict(),
scm_clean=dict(type='bool', default=False),
scm_delete_on_update=dict(type='bool', default=False),
scm_update_on_launch=dict(type='bool', default=False),
scm_update_cache_timeout=dict(type='int'),
job_timeout=dict(type='int', default=0),
custom_virtualenv=dict(type='str', required=False),
local_path=dict(),
state=dict(choices=['present', 'absent'], default='present'),
wait=dict(type='bool', default=True),
description=dict(required=False),
scm_type=dict(required=False, choices=['manual', 'git', 'hg', 'svn', 'insights'], default='manual'),
scm_url=dict(required=False),
local_path=dict(required=False),
scm_branch=dict(required=False, default=''),
scm_refspec=dict(required=False, default=''),
scm_credential=dict(required=False),
scm_clean=dict(required=False, type='bool', default=False),
scm_delete_on_update=dict(required=False, type='bool', default=False),
scm_update_on_launch=dict(required=False, type='bool', default=False),
scm_update_cache_timeout=dict(required=False, type='int', default=0),
scm_allow_override=dict(required=False, type='bool'),
job_timeout=dict(required=False, type='int', default=0),
custom_virtualenv=dict(required=False, type='str'),
organization=dict(required=True),
state=dict(required=False, choices=['present', 'absent'], default='present'),
wait=dict(required=False, type='bool', default=True),
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Extract our parameters
name = module.params.get('name')
description = module.params.get('description')
organization = module.params.get('organization')
scm_type = module.params.get('scm_type')
if scm_type == "manual":
scm_type = ""
scm_url = module.params.get('scm_url')
local_path = module.params.get('local_path')
scm_branch = module.params.get('scm_branch')
scm_refspec = module.params.get('scm_refspec')
scm_credential = module.params.get('scm_credential')
scm_clean = module.params.get('scm_clean')
scm_delete_on_update = module.params.get('scm_delete_on_update')
scm_update_on_launch = module.params.get('scm_update_on_launch')
scm_update_cache_timeout = module.params.get('scm_update_cache_timeout')
scm_allow_override = module.params.get('scm_allow_override')
job_timeout = module.params.get('job_timeout')
custom_virtualenv = module.params.get('custom_virtualenv')
organization = module.params.get('organization')
state = module.params.get('state')
wait = module.params.get('wait')
json_output = {'project': name, 'state': state}
# Attempt to look up the related items the user specified (these will fail the module if not found)
org_id = module.resolve_name_to_id('organizations', organization)
if scm_credential is not None:
scm_credential_id = module.resolve_name_to_id('credentials', scm_credential)
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
project = tower_cli.get_resource('project')
try:
if state == 'present':
try:
org_res = tower_cli.get_resource('organization')
org = org_res.get(name=organization)
except exc.NotFound:
module.fail_json(msg='Failed to update project, organization not found: {0}'.format(organization), changed=False)
# Attempt to look up project based on the provided name and org ID
project = module.get_one('projects', **{
'data': {
'name': name,
'organization': org_id
}
})
if scm_credential:
try:
cred_res = tower_cli.get_resource('credential')
try:
cred = cred_res.get(name=scm_credential)
except tower_cli.exceptions.MultipleResults:
module.warn('Multiple credentials found for {0}, falling back looking in project organization'.format(scm_credential))
cred = cred_res.get(name=scm_credential, organization=org['id'])
scm_credential = cred['id']
except exc.NotFound:
module.fail_json(msg='Failed to update project, credential not found: {0}'.format(scm_credential), changed=False)
# Create the data that gets sent for create and update
project_fields = {
'name': name,
'scm_type': scm_type,
'scm_url': scm_url,
'scm_branch': scm_branch,
'scm_refspec': scm_refspec,
'scm_clean': scm_clean,
'scm_delete_on_update': scm_delete_on_update,
'timeout': job_timeout,
'organization': org_id,
'scm_update_on_launch': scm_update_on_launch,
'scm_update_cache_timeout': scm_update_cache_timeout,
'custom_virtualenv': custom_virtualenv,
}
if description is not None:
project_fields['description'] = description
if scm_credential is not None:
project_fields['credential'] = scm_credential_id
if scm_allow_override is not None:
project_fields['scm_allow_override'] = scm_allow_override
if scm_type == '':
project_fields['local_path'] = local_path
if (scm_update_cache_timeout is not None) and (scm_update_on_launch is not True):
module.warn('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true')
if state != 'absent' and (scm_update_cache_timeout != 0 and scm_update_on_launch is not True):
module.warn('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true')
result = project.modify(name=name, description=description,
organization=org['id'],
scm_type=scm_type, scm_url=scm_url, local_path=local_path,
scm_branch=scm_branch, scm_clean=scm_clean, credential=scm_credential,
scm_delete_on_update=scm_delete_on_update,
scm_update_on_launch=scm_update_on_launch,
scm_update_cache_timeout=scm_update_cache_timeout,
job_timeout=job_timeout,
custom_virtualenv=custom_virtualenv,
create_on_missing=True)
json_output['id'] = result['id']
if wait and scm_type != '':
project.wait(pk=None, parent_pk=result['id'])
elif state == 'absent':
result = project.delete(name=name)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update project: {0}'.format(excinfo), changed=False)
# If we are doing a not manual project, register our on_change method
# An on_change function, if registered, will fire after an post_endpoint or update_if_needed completes successfully
on_change = None
if wait and scm_type != '':
on_change = wait_for_project_update
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(project)
elif state == 'present':
# If the state was present and we can let the module build or update the existing project, this will return on its own
module.create_or_update_if_needed(project, project_fields, endpoint='projects', item_type='project', on_create=on_change, on_update=on_change)
if __name__ == '__main__':

View File

@ -33,56 +33,67 @@ options:
- List of organization names to export
default: []
type: list
elements: str
user:
description:
- List of user names to export
default: []
type: list
elements: str
team:
description:
- List of team names to export
default: []
type: list
elements: str
credential_type:
description:
- List of credential type names to export
default: []
type: list
elements: str
credential:
description:
- List of credential names to export
default: []
type: list
elements: str
notification_template:
description:
- List of notification template names to export
default: []
type: list
elements: str
inventory_script:
description:
- List of inventory script names to export
default: []
type: list
elements: str
inventory:
description:
- List of inventory names to export
default: []
type: list
elements: str
project:
description:
- List of project names to export
default: []
type: list
elements: str
job_template:
description:
- List of job template names to export
default: []
type: list
elements: str
workflow:
description:
- List of workflow names to export
default: []
type: list
elements: str
requirements:
- "ansible-tower-cli >= 3.3.0"
@ -136,21 +147,23 @@ except ImportError:
def main():
argument_spec = dict(
all=dict(type='bool', default=False),
credential=dict(type='list', default=[]),
credential_type=dict(type='list', default=[]),
inventory=dict(type='list', default=[]),
inventory_script=dict(type='list', default=[]),
job_template=dict(type='list', default=[]),
notification_template=dict(type='list', default=[]),
organization=dict(type='list', default=[]),
project=dict(type='list', default=[]),
team=dict(type='list', default=[]),
user=dict(type='list', default=[]),
workflow=dict(type='list', default=[]),
credential=dict(type='list', default=[], elements='str'),
credential_type=dict(type='list', default=[], elements='str'),
inventory=dict(type='list', default=[], elements='str'),
inventory_script=dict(type='list', default=[], elements='str'),
job_template=dict(type='list', default=[], elements='str'),
notification_template=dict(type='list', default=[], elements='str'),
organization=dict(type='list', default=[], elements='str'),
project=dict(type='list', default=[], elements='str'),
team=dict(type='list', default=[], elements='str'),
user=dict(type='list', default=[], elements='str'),
workflow=dict(type='list', default=[], elements='str'),
)
module = TowerModule(argument_spec=argument_spec, supports_check_mode=False)
module.deprecate(msg="This module is being moved to a different collection. Instead of awx.awx it will be migrated into awx.tower_cli", version="3.7")
if not HAS_TOWER_CLI:
module.fail_json(msg='ansible-tower-cli required for this module')

View File

@ -68,6 +68,10 @@ options:
default: "present"
choices: ["present", "absent"]
type: str
requirements:
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
'''
@ -137,6 +141,8 @@ def main():
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
module.deprecate(msg="This module is being moved to a different collection. Instead of awx.awx it will be migrated into awx.tower_cli", version="3.7")
role_type = module.params.pop('role')
state = module.params.pop('state')

View File

@ -35,12 +35,14 @@ options:
required: False
default: []
type: list
elements: str
prevent:
description:
- A list of asset types to prevent import for
required: false
default: []
type: list
elements: str
password_management:
description:
- The password management option to use.
@ -97,13 +99,15 @@ except ImportError:
def main():
argument_spec = dict(
assets=dict(required=False),
files=dict(required=False, default=[], type='list'),
prevent=dict(required=False, default=[], type='list'),
files=dict(required=False, default=[], type='list', elements='str'),
prevent=dict(required=False, default=[], type='list', elements='str'),
password_management=dict(required=False, default='default', choices=['default', 'random']),
)
module = TowerModule(argument_spec=argument_spec, supports_check_mode=False)
module.deprecate(msg="This module is being moved to a different collection. Instead of awx.awx it will be migrated into awx.tower_cli", version="3.7")
if not HAS_TOWER_CLI:
module.fail_json(msg='ansible-tower-cli required for this module')

View File

@ -26,18 +26,28 @@ options:
name:
description:
- Name of setting to modify
required: True
required: False
type: str
value:
description:
- Value to be modified for given setting.
required: True
required: False
type: str
settings:
description:
- A data structure to be sent into the settings endpoint
required: False
type: dict
version_added: "3.7"
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
RETURN = ''' # '''
EXAMPLES = '''
- name: Set the value of AWX_PROOT_BASE_PATH
tower_settings:
@ -56,50 +66,98 @@ EXAMPLES = '''
name: "AUTH_LDAP_BIND_PASSWORD"
value: "Password"
no_log: true
- name: Set all the LDAP Auth Bind Params
tower_settings:
settings:
AUTH_LDAP_BIND_PASSWORD: "password"
AUTH_LDAP_USER_ATTR_MAP:
email: "mail"
first_name: "givenName"
last_name: "surname"
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
from json import loads
import re
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
value=dict(required=True),
name=dict(required=False),
value=dict(required=False),
settings=dict(required=False, type='dict'),
)
# Create a module for ourselves
module = TowerModule(
argument_spec=argument_spec,
supports_check_mode=False
supports_check_mode=True,
required_one_of=[['name', 'settings']],
mutually_exclusive=[['name', 'settings']],
required_if=[['name', 'present', ['value']]]
)
json_output = {}
# Extract our parameters
name = module.params.get('name')
value = module.params.get('value')
new_settings = module.params.get('settings')
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
# If we were given a name/value pair we will just make settings out of that and proceed normally
if new_settings is None:
new_value = value
try:
setting = tower_cli.get_resource('setting')
result = setting.modify(setting=name, value=value)
new_value = loads(value)
except ValueError:
# JSONDecodeError only available on Python 3.5+
# Attempt to deal with old tower_cli array types
if ',' in value:
new_value = re.split(r",\s+", new_value)
json_output['id'] = result['id']
json_output['value'] = result['value']
new_settings = {name: new_value}
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to modify the setting: {0}'.format(excinfo), changed=False)
# Load the existing settings
existing_settings = module.get_endpoint('settings/all')['json']
json_output['changed'] = result['changed']
module.exit_json(**json_output)
# Begin a json response
json_response = {'changed': False, 'old_values': {}}
# Check any of the settings to see if anything needs to be updated
needs_update = False
for a_setting in new_settings:
if a_setting not in existing_settings or existing_settings[a_setting] != new_settings[a_setting]:
# At least one thing is different so we need to patch
needs_update = True
json_response['old_values'][a_setting] = existing_settings[a_setting]
# If nothing needs an update we can simply exit with the response (as not changed)
if not needs_update:
module.exit_json(**json_response)
# Make the call to update the settings
response = module.patch_endpoint('settings/all', **{'data': new_settings})
if response['status_code'] == 200:
# Set the changed response to True
json_response['changed'] = True
# To deal with the old style values we need to return 'value' in the response
new_values = {}
for a_setting in new_settings:
new_values[a_setting] = response['json'][a_setting]
# If we were using a name we will just add a value of a string, otherwise we will return an array in values
if name is not None:
json_response['value'] = new_values[name]
else:
json_response['values'] = new_values
module.exit_json(**json_response)
elif 'json' in response and '__all__' in response['json']:
module.fail_json(msg=response['json']['__all__'])
else:
module.fail_json(**{'msg': "Unable to update settings, see response", 'response': response})
if __name__ == '__main__':

View File

@ -28,9 +28,16 @@ options:
- Name to use for the team.
required: True
type: str
new_name:
description:
- To use when changing a team's name.
required: False
type: str
version_added: "3.7"
description:
description:
- The description to use for the team.
required: False
type: str
organization:
description:
@ -43,6 +50,12 @@ options:
choices: ["present", "absent"]
default: "present"
type: str
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -57,57 +70,54 @@ EXAMPLES = '''
tower_config_file: "~/tower_cli.cfg"
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
name=dict(required=True),
description=dict(),
new_name=dict(required=False),
description=dict(required=False),
organization=dict(required=True),
state=dict(choices=['present', 'absent'], default='present'),
)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Extract our parameters
name = module.params.get('name')
new_name = module.params.get('new_name')
description = module.params.get('description')
organization = module.params.get('organization')
state = module.params.get('state')
json_output = {'team': name, 'state': state}
# Attempt to look up the related items the user specified (these will fail the module if not found)
org_id = module.resolve_name_to_id('organizations', organization)
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
team = tower_cli.get_resource('team')
# Attempt to look up team based on the provided name and org ID
team = module.get_one('teams', **{
'data': {
'name': name,
'organization': org_id
}
})
try:
org_res = tower_cli.get_resource('organization')
org = org_res.get(name=organization)
# Create the data that gets sent for create and update
team_fields = {
'name': new_name if new_name else name,
'organization': org_id
}
if description is not None:
team_fields['description'] = description
if state == 'present':
result = team.modify(name=name, organization=org['id'],
description=description, create_on_missing=True)
json_output['id'] = result['id']
elif state == 'absent':
result = team.delete(name=name, organization=org['id'])
except (exc.NotFound) as excinfo:
module.fail_json(msg='Failed to update team, organization not found: {0}'.format(excinfo), changed=False)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update team: {0}'.format(excinfo), changed=False)
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(team)
elif state == 'present':
# If the state was present and we can let the module build or update the existing team, this will return on its own
module.create_or_update_if_needed(team, team_fields, endpoint='teams', item_type='team')
if __name__ == '__main__':

View File

@ -38,8 +38,8 @@ options:
type: str
email:
description:
- Email address of the user.
required: True
- Email address of the user. Required if creating a new user.
required: False
type: str
password:
description:
@ -61,10 +61,12 @@ options:
default: "present"
choices: ["present", "absent"]
type: str
requirements:
- ansible-tower-cli >= 3.2.0
tower_oauthtoken:
description:
- The Tower OAuth token to use.
required: False
type: str
version_added: "3.7"
extends_documentation_fragment: awx.awx.auth
'''
@ -106,59 +108,54 @@ EXAMPLES = '''
tower_config_file: "~/tower_cli.cfg"
'''
from ..module_utils.ansible_tower import TowerModule, tower_auth_config, tower_check_mode
try:
import tower_cli
import tower_cli.exceptions as exc
from tower_cli.conf import settings
except ImportError:
pass
from ..module_utils.tower_api import TowerModule
def main():
# Any additional arguments that are not fields of the item can be added here
argument_spec = dict(
username=dict(required=True),
first_name=dict(),
last_name=dict(),
password=dict(no_log=True),
email=dict(required=True),
email=dict(required=False, default=''),
superuser=dict(type='bool', default=False),
auditor=dict(type='bool', default=False),
state=dict(choices=['present', 'absent'], default='present'),
)
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True)
# Create a module for ourselves
module = TowerModule(argument_spec=argument_spec, supports_check_mode=True, required_if=[['state', 'present', ['email']]])
username = module.params.get('username')
first_name = module.params.get('first_name')
last_name = module.params.get('last_name')
password = module.params.get('password')
email = module.params.get('email')
superuser = module.params.get('superuser')
auditor = module.params.get('auditor')
# Extract our parameters
state = module.params.get('state')
email = module.params.get('email')
json_output = {'username': username, 'state': state}
# Create the data that gets sent for create and update
user_fields = {
'username': module.params.get('username'),
'first_name': module.params.get('first_name'),
'last_name': module.params.get('last_name'),
'password': module.params.get('password'),
'superuser': module.params.get('superuser'),
'auditor': module.params.get('auditor'),
}
if email is not None:
user_fields['email'] = email
tower_auth = tower_auth_config(module)
with settings.runtime_values(**tower_auth):
tower_check_mode(module)
user = tower_cli.get_resource('user')
try:
if state == 'present':
result = user.modify(username=username, first_name=first_name, last_name=last_name,
email=email, password=password, is_superuser=superuser,
is_system_auditor=auditor, create_on_missing=True)
json_output['id'] = result['id']
elif state == 'absent':
result = user.delete(username=username)
except (exc.ConnectionError, exc.BadRequest, exc.AuthError) as excinfo:
module.fail_json(msg='Failed to update the user: {0}'.format(excinfo), changed=False)
# Attempt to look up user based on the provided username
user = module.get_one('users', **{
'data': {
'username': user_fields['username'],
}
})
json_output['changed'] = result['changed']
module.exit_json(**json_output)
if state == 'absent':
# If the state was absent we can let the module delete it if needed, the module will handle exiting from this
module.delete_if_needed(user)
elif state == 'present':
# If the state was present and we can let the module build or update the existing user, this will return on its own
module.create_or_update_if_needed(user, user_fields, endpoint='users', item_type='user')
if __name__ == '__main__':

View File

@ -44,6 +44,8 @@ options:
requirements:
- "python >= 2.6"
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
'''

View File

@ -81,6 +81,10 @@ options:
default: "present"
choices: ["present", "absent"]
type: str
requirements:
- ansible-tower-cli >= 3.0.2
extends_documentation_fragment: awx.awx.auth
'''

View File

@ -5,7 +5,7 @@ import io
import json
import datetime
import importlib
from contextlib import redirect_stdout
from contextlib import redirect_stdout, suppress
from unittest import mock
import logging
@ -16,6 +16,12 @@ import pytest
from awx.main.tests.functional.conftest import _request
from awx.main.models import Organization, Project, Inventory, Credential, CredentialType
try:
import tower_cli # noqa
HAS_TOWER_CLI = True
except ImportError:
HAS_TOWER_CLI = False
logger = logging.getLogger('awx.main.tests')
@ -41,13 +47,28 @@ def sanitize_dict(din):
@pytest.fixture
def run_module(request):
def collection_import():
"""These tests run assuming that the awx_collection folder is inserted
into the PATH before-hand. But all imports internally to the collection
go through this fixture so that can be changed if needed.
For instance, we could switch to fully-qualified import paths.
"""
def rf(path):
return importlib.import_module(path)
return rf
@pytest.fixture
def run_module(request, collection_import):
def rf(module_name, module_params, request_user):
def new_request(self, method, url, **kwargs):
kwargs_copy = kwargs.copy()
if 'data' in kwargs:
kwargs_copy['data'] = json.loads(kwargs['data'])
if not isinstance(kwargs['data'], dict):
kwargs_copy['data'] = json.loads(kwargs['data'])
else:
kwargs_copy['data'] = kwargs['data']
if 'params' in kwargs and method == 'GET':
# query params for GET are handled a bit differently by
# tower-cli and python requests as opposed to REST framework APIRequestFactory
@ -79,12 +100,16 @@ def run_module(request):
return resp
def new_open(self, method, url, **kwargs):
r = new_request(self, method, url, **kwargs)
return mock.MagicMock(read=mock.MagicMock(return_value=r._content), status=r.status_code)
stdout_buffer = io.StringIO()
# Requies specific PYTHONPATH, see docs
# Note that a proper Ansiballz explosion of the modules will have an import path like:
# ansible_collections.awx.awx.plugins.modules.{}
# We should consider supporting that in the future
resource_module = importlib.import_module('plugins.modules.{0}'.format(module_name))
resource_module = collection_import('plugins.modules.{0}'.format(module_name))
if not isinstance(module_params, dict):
raise RuntimeError('Module params must be dict, got {0}'.format(type(module_params)))
@ -96,16 +121,24 @@ def run_module(request):
with mock.patch.object(resource_module.TowerModule, '_load_params', new=mock_load_params):
# Call the test utility (like a mock server) instead of issuing HTTP requests
with mock.patch('tower_cli.api.Session.request', new=new_request):
# Ansible modules return data to the mothership over stdout
with redirect_stdout(stdout_buffer):
try:
resource_module.main()
except SystemExit:
pass # A system exit indicates successful execution
with mock.patch('ansible.module_utils.urls.Request.open', new=new_open):
if HAS_TOWER_CLI:
tower_cli_mgr = mock.patch('tower_cli.api.Session.request', new=new_request)
else:
tower_cli_mgr = suppress()
with tower_cli_mgr:
# Ansible modules return data to the mothership over stdout
with redirect_stdout(stdout_buffer):
try:
resource_module.main()
except SystemExit:
pass # A system exit indicates successful execution
module_stdout = stdout_buffer.getvalue().strip()
result = json.loads(module_stdout)
# A module exception should never be a test expectation
if 'exception' in result:
raise Exception('Module encountered error:\n{0}'.format(result['exception']))
return result
return rf

View File

@ -79,12 +79,10 @@ def test_create_custom_credential_type(run_module, admin_user):
ct = CredentialType.objects.get(name='Nexus')
result.pop('invocation')
assert result == {
"credential_type": "Nexus",
"state": "present",
"name": "Nexus",
"id": ct.pk,
"changed": True
"changed": True,
}
assert ct.inputs == {"fields": [{"id": "server", "type": "string", "default": "", "label": ""}], "required": []}
assert ct.injectors == {'extra_vars': {'nexus_credential': 'test'}}

View File

@ -10,25 +10,25 @@ from awx.main.models import Organization, Inventory, Group
def test_create_group(run_module, admin_user):
org = Organization.objects.create(name='test-org')
inv = Inventory.objects.create(name='test-inv', organization=org)
variables = {"ansible_network_os": "iosxr"}
result = run_module('tower_group', dict(
name='Test Group',
inventory='test-inv',
variables='ansible_network_os: iosxr',
variables=variables,
state='present'
), admin_user)
assert result.get('changed'), result
group = Group.objects.get(name='Test Group')
assert group.inventory == inv
assert group.variables == 'ansible_network_os: iosxr'
assert group.variables == '{"ansible_network_os": "iosxr"}'
result.pop('invocation')
assert result == {
'id': group.id,
'group': 'Test Group',
'name': 'Test Group',
'changed': True,
'state': 'present'
}
@ -40,20 +40,16 @@ def test_tower_group_idempotent(run_module, admin_user):
group = Group.objects.create(
name='Test Group',
inventory=inv,
variables='ansible_network_os: iosxr'
)
result = run_module('tower_group', dict(
name='Test Group',
inventory='test-inv',
variables='ansible_network_os: iosxr',
state='present'
), admin_user)
result.pop('invocation')
assert result == {
'id': group.id,
'group': 'Test Group',
'changed': False, # idempotency assertion
'state': 'present'
}

View File

@ -10,23 +10,29 @@ from awx.main.models import Organization, Inventory, InventorySource, Project
def base_inventory():
org = Organization.objects.create(name='test-org')
inv = Inventory.objects.create(name='test-inv', organization=org)
Project.objects.create(
name='test-proj',
organization=org,
scm_type='git',
scm_url='https://github.com/ansible/test-playbooks.git',
)
return inv
@pytest.fixture
def project(base_inventory):
return Project.objects.create(
name='test-proj',
organization=base_inventory.organization,
scm_type='git',
scm_url='https://github.com/ansible/test-playbooks.git',
)
@pytest.mark.django_db
def test_inventory_source_create(run_module, admin_user, base_inventory):
def test_inventory_source_create(run_module, admin_user, base_inventory, project):
source_path = '/var/lib/awx/example_source_path/'
result = run_module('tower_inventory_source', dict(
name='foo',
inventory='test-inv',
inventory=base_inventory.name,
state='present',
source='scm',
source_project='test-proj'
source_path=source_path,
source_project=project.name
), admin_user)
assert result.pop('changed', None), result
@ -35,8 +41,7 @@ def test_inventory_source_create(run_module, admin_user, base_inventory):
result.pop('invocation')
assert result == {
'id': inv_src.id,
'inventory_source': 'foo',
'state': 'present'
'name': 'foo',
}
@ -45,6 +50,7 @@ def test_create_inventory_source_implied_org(run_module, admin_user):
org = Organization.objects.create(name='test-org')
inv = Inventory.objects.create(name='test-inv', organization=org)
# Credential is not required for ec2 source, because of IAM roles
result = run_module('tower_inventory_source', dict(
name='Test Inventory Source',
inventory='test-inv',
@ -58,8 +64,7 @@ def test_create_inventory_source_implied_org(run_module, admin_user):
result.pop('invocation')
assert result == {
"inventory_source": "Test Inventory Source",
"state": "present",
"name": "Test Inventory Source",
"id": inv_src.id,
}
@ -67,43 +72,43 @@ def test_create_inventory_source_implied_org(run_module, admin_user):
@pytest.mark.django_db
def test_create_inventory_source_multiple_orgs(run_module, admin_user):
org = Organization.objects.create(name='test-org')
inv = Inventory.objects.create(name='test-inv', organization=org)
Inventory.objects.create(name='test-inv', organization=org)
# make another inventory by same name in another org
org2 = Organization.objects.create(name='test-org-number-two')
Inventory.objects.create(name='test-inv', organization=org2)
inv2 = Inventory.objects.create(name='test-inv', organization=org2)
result = run_module('tower_inventory_source', dict(
name='Test Inventory Source',
inventory='test-inv',
inventory=inv2.id,
source='ec2',
organization='test-org',
state='present'
), admin_user)
assert result.pop('changed', None), result
inv_src = InventorySource.objects.get(name='Test Inventory Source')
assert inv_src.inventory == inv
assert inv_src.inventory == inv2
result.pop('invocation')
assert result == {
"inventory_source": "Test Inventory Source",
"state": "present",
"name": "Test Inventory Source",
"id": inv_src.id,
}
@pytest.mark.django_db
def test_create_inventory_source_with_venv(run_module, admin_user, base_inventory, mocker):
def test_create_inventory_source_with_venv(run_module, admin_user, base_inventory, mocker, project):
path = '/var/lib/awx/venv/custom-venv/foobar13489435/'
source_path = '/var/lib/awx/example_source_path/'
with mocker.patch('awx.main.models.mixins.get_custom_venv_choices', return_value=[path]):
result = run_module('tower_inventory_source', dict(
name='foo',
inventory='test-inv',
inventory=base_inventory.name,
state='present',
source='scm',
source_project='test-proj',
custom_virtualenv=path
source_project=project.name,
custom_virtualenv=path,
source_path=source_path
), admin_user)
assert result.pop('changed'), result
@ -115,16 +120,17 @@ def test_create_inventory_source_with_venv(run_module, admin_user, base_inventor
@pytest.mark.django_db
def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker):
def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker, project):
"""If the inventory source is modified, then it should not blank fields
unrelated to the params that the user passed.
This enforces assumptions about the behavior of the AnsibleModule
default argument_spec behavior.
"""
source_path = '/var/lib/awx/example_source_path/'
inv_src = InventorySource.objects.create(
name='foo',
inventory=base_inventory,
source_project=Project.objects.get(name='test-proj'),
source_project=project,
source='scm',
custom_virtualenv='/venv/foobar/'
)
@ -133,11 +139,93 @@ def test_custom_venv_no_op(run_module, admin_user, base_inventory, mocker):
result = run_module('tower_inventory_source', dict(
name='foo',
description='this is the changed description',
inventory='test-inv',
inventory=base_inventory.name,
source='scm', # is required, but behavior is arguable
state='present'
state='present',
source_project=project.name,
source_path=source_path
), admin_user)
assert result.pop('changed', None), result
inv_src.refresh_from_db()
assert inv_src.custom_virtualenv == '/venv/foobar/'
assert inv_src.description == 'this is the changed description'
# Tests related to source-specific parameters
#
# We want to let the API return issues with "this doesn't support that", etc.
#
# GUI OPTIONS:
# - - - - - - - manual: file: scm: ec2: gce azure_rm vmware sat cloudforms openstack rhv tower custom
# credential ? ? o o r r r r r r r r o
# source_project ? ? r - - - - - - - - - -
# source_path ? ? r - - - - - - - - - -
# verbosity ? ? o o o o o o o o o o o
# overwrite ? ? o o o o o o o o o o o
# overwrite_vars ? ? o o o o o o o o o o o
# update_on_launch ? ? o o o o o o o o o o o
# UoPL ? ? o - - - - - - - - - -
# source_regions ? ? - o o o - - - - - - -
# instance_filters ? ? - o - - o - - - - o -
# group_by ? ? - o - - o - - - - - -
# source_vars* ? ? - o - o o o o o - - -
# environmet vars* ? ? o - - - - - - - - - o
# source_script ? ? - - - - - - - - - - r
#
# UoPL - update_on_project_launch
# * - source_vars are labeled environment_vars on project and custom sources
@pytest.mark.django_db
def test_missing_required_credential(run_module, admin_user, base_inventory):
result = run_module('tower_inventory_source', dict(
name='Test Azure Source',
inventory=base_inventory.name,
source='azure_rm',
state='present'
), admin_user)
assert result.pop('failed', None) is True, result
assert 'Credential is required for a cloud source' in result.get('msg', '')
@pytest.mark.django_db
def test_source_project_not_for_cloud(run_module, admin_user, base_inventory, project):
result = run_module('tower_inventory_source', dict(
name='Test ec2 Inventory Source',
inventory=base_inventory.name,
source='ec2',
state='present',
source_project=project.name
), admin_user)
assert result.pop('failed', None) is True, result
assert 'Cannot set source_project if not SCM type' in result.get('msg', '')
@pytest.mark.django_db
def test_source_path_not_for_cloud(run_module, admin_user, base_inventory):
result = run_module('tower_inventory_source', dict(
name='Test ec2 Inventory Source',
inventory=base_inventory.name,
source='ec2',
state='present',
source_path='where/am/I'
), admin_user)
assert result.pop('failed', None) is True, result
assert 'Cannot set source_path if not SCM type' in result.get('msg', '')
@pytest.mark.django_db
def test_scm_source_needs_project(run_module, admin_user, base_inventory):
result = run_module('tower_inventory_source', dict(
name='SCM inventory without project',
inventory=base_inventory.name,
state='present',
source='scm',
source_path='/var/lib/awx/example_source_path/'
), admin_user)
assert result.pop('failed', None), result
assert 'Project required for scm type sources' in result.get('msg', '')

View File

@ -0,0 +1,35 @@
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import sys
from unittest import mock
import json
def test_duplicate_config(collection_import):
# imports done here because of PATH issues unique to this test suite
TowerModule = collection_import('plugins.module_utils.tower_api').TowerModule
data = {
'name': 'zigzoom',
'zig': 'zoom',
'tower_username': 'bob',
'tower_config_file': 'my_config'
}
cli_data = {'ANSIBLE_MODULE_ARGS': data}
testargs = ['module_file.py', json.dumps(cli_data)]
with mock.patch('ansible.module_utils.basic.AnsibleModule.warn') as mock_warn:
with mock.patch.object(sys, 'argv', testargs):
with mock.patch.object(TowerModule, 'load_config') as mock_load:
argument_spec = dict(
name=dict(required=True),
zig=dict(type='str'),
)
TowerModule(argument_spec=argument_spec)
mock_load.mock_calls[-1] == mock.call('my_config')
mock_warn.assert_called_once_with(
'The parameter(s) tower_username were provided at the same time as '
'tower_config_file. Precedence may be unstable, '
'we suggest either using config file or params.'
)

View File

@ -9,18 +9,28 @@ from awx.main.models import Organization
@pytest.mark.django_db
def test_create_organization(run_module, admin_user):
module_args = {'name': 'foo', 'description': 'barfoo', 'state': 'present'}
module_args = {
'name': 'foo',
'description': 'barfoo',
'state': 'present',
'max_hosts': '0',
'tower_host': None,
'tower_username': None,
'tower_password': None,
'validate_certs': None,
'tower_oauthtoken': None,
'tower_config_file': None,
'custom_virtualenv': None
}
result = run_module('tower_organization', module_args, admin_user)
assert result.get('changed'), result
org = Organization.objects.get(name='foo')
assert result == {
"organization": "foo",
"state": "present",
"id": org.id,
"name": "foo",
"changed": True,
"id": org.id,
"invocation": {
"module_args": module_args
}
@ -42,10 +52,8 @@ def test_create_organization_with_venv(run_module, admin_user, mocker):
org = Organization.objects.get(name='foo')
result.pop('invocation')
assert result == {
"organization": "foo",
"state": "present",
"name": "foo",
"id": org.id
}

View File

@ -3,18 +3,23 @@ __metaclass__ = type
import pytest
from unittest import mock
from awx.main.models import Project
@pytest.mark.django_db
def test_create_project(run_module, admin_user, organization):
result = run_module('tower_project', dict(
name='foo',
organization=organization.name,
scm_type='git',
scm_url='https://foo.invalid',
wait=False
), admin_user)
with mock.patch('ansible.module_utils.basic.AnsibleModule.warn') as mock_warn:
result = run_module('tower_project', dict(
name='foo',
organization=organization.name,
scm_type='git',
scm_url='https://foo.invalid',
wait=False,
scm_update_cache_timeout=5
), admin_user)
mock_warn.assert_called_once_with('scm_update_cache_timeout will be ignored since scm_update_on_launch was not set to true')
assert result.pop('changed', None), result
proj = Project.objects.get(name='foo')
@ -23,7 +28,6 @@ def test_create_project(run_module, admin_user, organization):
result.pop('invocation')
assert result == {
'id': proj.id,
'project': 'foo',
'state': 'present'
'name': 'foo',
'id': proj.id
}

View File

@ -2,6 +2,7 @@ from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import pytest
from unittest import mock
import json
from awx.main.models import (
@ -65,7 +66,9 @@ def test_receive_send_jt(run_module, admin_user, mocker):
# recreate everything
with mocker.patch('sys.stdin.isatty', return_value=True):
with mocker.patch('tower_cli.models.base.MonitorableResource.wait'):
result = run_module('tower_send', dict(assets=json.dumps(assets)), admin_user)
# warns based on password_management param, but not security issue
with mock.patch('ansible.module_utils.basic.AnsibleModule.warn'):
result = run_module('tower_send', dict(assets=json.dumps(assets)), admin_user)
assert not result.get('failed'), result

View File

@ -0,0 +1,66 @@
from __future__ import (absolute_import, division, print_function)
__metaclass__ = type
import pytest
from awx.main.models import Organization, Team
@pytest.mark.django_db
def test_create_team(run_module, admin_user):
org = Organization.objects.create(name='foo')
result = run_module('tower_team', {
'name': 'foo_team',
'description': 'fooin around',
'state': 'present',
'organization': 'foo'
}, admin_user)
team = Team.objects.filter(name='foo_team').first()
result.pop('invocation')
assert result == {
"changed": True,
"name": "foo_team",
"id": team.id if team else None,
}
team = Team.objects.get(name='foo_team')
assert team.description == 'fooin around'
assert team.organization_id == org.id
@pytest.mark.django_db
def test_modify_team(run_module, admin_user):
org = Organization.objects.create(name='foo')
team = Team.objects.create(
name='foo_team',
organization=org,
description='flat foo'
)
assert team.description == 'flat foo'
result = run_module('tower_team', {
'name': 'foo_team',
'description': 'fooin around',
'organization': 'foo'
}, admin_user)
team.refresh_from_db()
result.pop('invocation')
assert result == {
"changed": True,
"id": team.id,
}
assert team.description == 'fooin around'
# 2nd modification, should cause no change
result = run_module('tower_team', {
'name': 'foo_team',
'description': 'fooin around',
'organization': 'foo'
}, admin_user)
result.pop('invocation')
assert result == {
"id": team.id,
"changed": False
}

View File

@ -1,2 +0,0 @@
plugins/modules/tower_group.py use-argspec-type-path
plugins/modules/tower_host.py use-argspec-type-path

View File

@ -1,2 +0,0 @@
plugins/modules/tower_group.py use-argspec-type-path
plugins/modules/tower_host.py use-argspec-type-path