mirror of
https://github.com/ansible/awx.git
synced 2026-01-13 02:50:02 -03:30
Initial implementation of Pull #5337
This commit is contained in:
parent
22d4e60028
commit
0d5a9e9c8c
334
awx_collection/plugins/module_utils/tower_api.py
Normal file
334
awx_collection/plugins/module_utils/tower_api.py
Normal file
@ -0,0 +1,334 @@
|
||||
from __future__ import absolute_import, division, print_function
|
||||
__metaclass__ = type
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
from ansible.module_utils.basic import env_fallback
|
||||
from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError
|
||||
from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode
|
||||
|
||||
from socket import gethostbyname
|
||||
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, NoSectionError
|
||||
import re
|
||||
from json import loads, dumps
|
||||
from os.path import isfile
|
||||
from os import access, R_OK
|
||||
|
||||
|
||||
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
|
||||
json_output = {'changed': False}
|
||||
|
||||
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(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
|
||||
|
||||
super(TowerModule, self).__init__(argument_spec=args, **kwargs)
|
||||
|
||||
# If we have a tower config, load it
|
||||
if self.params.get('tower_config_file'):
|
||||
self.load_config(self.params.get('tower_config_file'))
|
||||
|
||||
# Parameters specified on command line will override settings in 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=self.cookie_jar)
|
||||
|
||||
def load_config(self, config_path):
|
||||
config = ConfigParser()
|
||||
# Validate the config file is an actual file
|
||||
if not isfile(config_path):
|
||||
self.fail_json(msg='The specified config file does not exist')
|
||||
|
||||
if not access(config_path, R_OK):
|
||||
self.fail_json(msg="The specified config file can not be read")
|
||||
|
||||
config.read(config_path)
|
||||
|
||||
for honorred_setting in self.honorred_settings:
|
||||
try:
|
||||
setattr(self, honorred_setting, config.get('general', honorred_setting))
|
||||
except (NoSectionError) as nse:
|
||||
self.fail_json(msg="The specified config file does not contain a general section ({0})".format(nse))
|
||||
except (NoOptionError):
|
||||
pass
|
||||
|
||||
def get_endpoint(self, endpoint, *args, **kwargs):
|
||||
return self.make_request('GET', endpoint, **kwargs)
|
||||
|
||||
def patch_endpoint(self, endpoint, *args, **kwargs):
|
||||
return self.make_request('PATCH', endpoint, **kwargs)
|
||||
|
||||
def post_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs):
|
||||
response = self.make_request('POST', endpoint, **kwargs)
|
||||
if response['status_code'] == 201:
|
||||
self.json_output['changed'] = True
|
||||
self.json_output['id'] = response['json']['id']
|
||||
self.exit_json(**self.json_output)
|
||||
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]))
|
||||
else:
|
||||
self.fail_json(msg="Unable to create {0} {1}: {2}".format(item_type, item_name, response['status_code']))
|
||||
|
||||
def delete_endpoint(self, endpoint, handle_return=True, item_type='item', item_name='', *args, **kwargs):
|
||||
response = self.make_request('DELETE', endpoint, **kwargs)
|
||||
if not handle_return:
|
||||
return response
|
||||
elif response['status_code'] == 204:
|
||||
self.json_output['changed'] = True
|
||||
self.exit_json(**self.json_output)
|
||||
else:
|
||||
self.fail_json(msg="Unable to delete {0} {1}: {2}".format(item_type, item_name, response['status_code']))
|
||||
|
||||
def get_all_endpoint(self, endpoint, *args, **kwargs):
|
||||
raise Exception("This is not implemented")
|
||||
|
||||
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:
|
||||
self.fail_json(msg="The {} {} was not found on the Tower server".format(endpoint, name_or_id))
|
||||
else:
|
||||
self.fail_json(msg="Found too many names {} at endpoint {}".format(name_or_id, endpoint))
|
||||
|
||||
def make_request(self, method, endpoint, *args, **kwargs):
|
||||
# Incase someone is calling us directly; make sure we were given a method, lets 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('/'):
|
||||
endpoint = "{}/".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 toekn we just use a bearer header
|
||||
headers['Authorization'] = 'Bearer {}'.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:
|
||||
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}
|
||||
# self.fail_json(msg='The Tower server claims it was sent a bad request.\n{0} {1}\nstatus code: {2}\n\nResponse: {3}'.format(
|
||||
# method, self.url.path, he.code, he.read()))
|
||||
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))
|
||||
|
||||
return {'status_code': response.status, '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))
|
||||
|
||||
try:
|
||||
response_json = loads(response.read())
|
||||
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 response: {0}".format(e))
|
||||
|
||||
# If we have neiter 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 update_if_needed(self, existing_item, new_item, handle_response=True, **existing_return):
|
||||
for field in new_item:
|
||||
# If the two items don't match and we are not comparing '' to None
|
||||
if existing_item.get(field, None) != new_item.get(field, None) and not (existing_item.get(field, None) is None and new_item.get(field, None) == ''):
|
||||
# something dosent match so lets do it
|
||||
response = self.patch_endpoint(existing_item['url'], **{'data': new_item})
|
||||
if not handle_response:
|
||||
return response
|
||||
elif response['status_code'] == 200:
|
||||
existing_return['changed'] = True
|
||||
existing_return['id'] = response['json'].get('id')
|
||||
self.exit_json(**existing_return)
|
||||
elif 'json' in response and '__all__' in response['json']:
|
||||
self.fail_json(msg=response['json']['__all__'])
|
||||
else:
|
||||
self.fail_json({'msg': "Unable to update object, see response", 'response': response})
|
||||
|
||||
# Since we made it here, we don't need to update, status ok
|
||||
existing_return['changed'] = False
|
||||
existing_return['id'] = existing_item.get('id')
|
||||
self.exit_json(**existing_return)
|
||||
|
||||
def logout(self):
|
||||
if self.oauth_token_id:
|
||||
try:
|
||||
self.delete_endpoint('tokens/{0}/'.format(self.oauth_token_id), handle_return=False)
|
||||
self.authenticated = False
|
||||
except Exception as e:
|
||||
self.fail_json(msg="Failed to logut: {0}".format(e))
|
||||
|
||||
def fail_json(self, **kwargs):
|
||||
# Try to logout if we are authenticated
|
||||
self.logout()
|
||||
super().fail_json(**kwargs)
|
||||
|
||||
def exit_json(self, **kwargs):
|
||||
# Try to logout if we are authenticated
|
||||
self.logout()
|
||||
super().exit_json(**kwargs)
|
||||
74
awx_collection/plugins/modules/tower_license.py
Normal file
74
awx_collection/plugins/modules/tower_license.py
Normal file
@ -0,0 +1,74 @@
|
||||
#!/usr/bin/python
|
||||
# coding: utf-8 -*-
|
||||
|
||||
# (c) 20189, 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: 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
|
||||
extends_documentation_fragment: awx.awx.auth
|
||||
'''
|
||||
|
||||
RETURN = ''' # '''
|
||||
|
||||
EXAMPLES = '''
|
||||
- name: Set the license using a file
|
||||
license:
|
||||
data: "{{ lookup('file', '/tmp/my_tower.license') }}"
|
||||
'''
|
||||
|
||||
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_acepte 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
|
||||
if module.check_mode:
|
||||
module.logout()
|
||||
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()
|
||||
@ -57,57 +57,68 @@ 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),
|
||||
new_name=dict(required=False),
|
||||
description=dict(),
|
||||
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}
|
||||
# We can either use the default check mode option or we can customize our own
|
||||
module.default_check_mode()
|
||||
|
||||
tower_auth = tower_auth_config(module)
|
||||
with settings.runtime_values(**tower_auth):
|
||||
tower_check_mode(module)
|
||||
team = tower_cli.get_resource('team')
|
||||
# Attempt to lookup the org the user specified
|
||||
org_id = module.resolve_name_to_id('organizations', organization)
|
||||
|
||||
try:
|
||||
org_res = tower_cli.get_resource('organization')
|
||||
org = org_res.get(name=organization)
|
||||
# Attempt to lookup team based on the provided name and org ID
|
||||
team = module.get_one('teams', **{
|
||||
'data': {
|
||||
'name': name,
|
||||
'organization': org_id
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
if state == 'absent' and not team:
|
||||
# If the state was absent and we had no team, we can just return
|
||||
module.exit_json(**module.json_output)
|
||||
elif state == 'absent' and team:
|
||||
# If the state was absent and we had a team, we can try to delete it, the module will handle exiting from this
|
||||
module.delete_endpoint('teams/{0}'.format(team['id']), item_type='team', item_name=name, **{})
|
||||
elif state == 'present' and not team:
|
||||
# if the state was present and we couldn't find a team we can build one, the module wikl handle exiting from this
|
||||
module.post_endpoint('teams', item_type='team', item_name=name, **{
|
||||
'data': {
|
||||
'name': name,
|
||||
'description': description,
|
||||
'organization': org_id
|
||||
}
|
||||
})
|
||||
else:
|
||||
# If the state was present and we had a team we can see if we need to update it
|
||||
# This will return on its own
|
||||
team_fields = {
|
||||
'name': new_name if new_name else name,
|
||||
'description': description,
|
||||
'organization': org_id,
|
||||
}
|
||||
|
||||
json_output['changed'] = result['changed']
|
||||
module.exit_json(**json_output)
|
||||
module.update_if_needed(team, team_fields)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user