Skip to content

Commit

Permalink
Merge pull request #7799 from john-westcott-iv/import-export-collecio…
Browse files Browse the repository at this point in the history
…n-modules

Adding import/export modules around AWX Kit features

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
  • Loading branch information
softwarefactory-project-zuul[bot] authored Aug 19, 2020
2 parents a3eff13 + a5afe02 commit cf116d1
Show file tree
Hide file tree
Showing 45 changed files with 866 additions and 314 deletions.
6 changes: 3 additions & 3 deletions awx_collection/plugins/inventory/tower.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
from ansible.plugins.inventory import BaseInventoryPlugin
from ansible.config.manager import ensure_type

from ..module_utils.tower_api import TowerModule
from ..module_utils.tower_api import TowerAPIModule


def handle_error(**kwargs):
Expand Down Expand Up @@ -104,12 +104,12 @@ def parse(self, inventory, loader, path, cache=True):

# Defer processing of params to logic shared with the modules
module_params = {}
for plugin_param, module_param in TowerModule.short_params.items():
for plugin_param, module_param in TowerAPIModule.short_params.items():
opt_val = self.get_option(plugin_param)
if opt_val is not None:
module_params[module_param] = opt_val

module = TowerModule(
module = TowerAPIModule(
argument_spec={}, direct_params=module_params,
error_callback=handle_error, warn_callback=self.warn_callback
)
Expand Down
6 changes: 3 additions & 3 deletions awx_collection/plugins/lookup/tower_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
from ansible.errors import AnsibleError
from ansible.module_utils._text import to_native
from ansible.utils.display import Display
from ..module_utils.tower_api import TowerModule
from ..module_utils.tower_api import TowerAPIModule


class LookupModule(LookupBase):
Expand All @@ -133,13 +133,13 @@ def run(self, terms, variables=None, **kwargs):

# Defer processing of params to logic shared with the modules
module_params = {}
for plugin_param, module_param in TowerModule.short_params.items():
for plugin_param, module_param in TowerAPIModule.short_params.items():
opt_val = self.get_option(plugin_param)
if opt_val is not None:
module_params[module_param] = opt_val

# Create our module
module = TowerModule(
module = TowerAPIModule(
argument_spec={}, direct_params=module_params,
error_callback=self.handle_error, warn_callback=self.warn_callback
)
Expand Down
242 changes: 11 additions & 231 deletions awx_collection/plugins/module_utils/tower_api.py
Original file line number Diff line number Diff line change
@@ -1,37 +1,18 @@
from __future__ import absolute_import, division, print_function
__metaclass__ = type

from ansible.module_utils.basic import AnsibleModule, env_fallback
from . tower_module import TowerModule
from ansible.module_utils.urls import Request, SSLValidationError, ConnectionError
from ansible.module_utils.six import PY2, string_types
from ansible.module_utils.six.moves import StringIO
from ansible.module_utils.six.moves.urllib.parse import urlparse, urlencode
from ansible.module_utils.six import PY2
from ansible.module_utils.six.moves.urllib.parse import 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):
class TowerAPIModule(TowerModule):
# TODO: Move the collection version check into tower_module.py
# This gets set by the make process so whatever is in here is irrelevant
_COLLECTION_VERSION = "0.0.1-devel"
_COLLECTION_TYPE = "awx"
Expand All @@ -41,197 +22,16 @@ class TowerModule(AnsibleModule):
'awx': 'AWX',
'tower': 'Red Hat Ansible Tower',
}
url = None
AUTH_ARGSPEC = 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='raw', no_log=True, required=False, fallback=(env_fallback, ['TOWER_OAUTH_TOKEN'])),
tower_config_file=dict(type='path', required=False, default=None),
)
short_params = {
'host': 'tower_host',
'username': 'tower_username',
'password': 'tower_password',
'verify_ssl': 'validate_certs',
'oauth_token': 'tower_oauthtoken',
}
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'
ENCRYPTED_STRING = "$encrypted$"
version_checked = False
error_callback = None
warn_callback = None

def __init__(self, argument_spec, direct_params=None, error_callback=None, warn_callback=None, **kwargs):
full_argspec = {}
full_argspec.update(TowerModule.AUTH_ARGSPEC)
full_argspec.update(argument_spec)
kwargs['supports_check_mode'] = True

self.error_callback = error_callback
self.warn_callback = warn_callback

self.json_output = {'changed': False}

if direct_params is not None:
self.params = direct_params
else:
super(TowerModule, self).__init__(argument_spec=full_argspec, **kwargs)

self.load_config_files()

# Parameters specified on command line will override settings in any config
for short_param, long_param in self.short_params.items():
direct_value = self.params.get(long_param)
if direct_value is not None:
setattr(self, short_param, direct_value)

# Perform magic depending on whether tower_oauthtoken is a string or a dict
if self.params.get('tower_oauthtoken'):
token_param = self.params.get('tower_oauthtoken')
if type(token_param) is dict:
if 'token' in token_param:
self.oauth_token = self.params.get('tower_oauthtoken')['token']
else:
self.fail_json(msg="The provided dict in tower_oauthtoken did not properly contain the token entry")
elif isinstance(token_param, string_types):
self.oauth_token = self.params.get('tower_oauthtoken')
else:
error_msg = "The provided tower_oauthtoken type was not valid ({0}). Valid options are str or dict.".format(type(token_param).__name__)
self.fail_json(msg=error_msg)

# 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))

super(TowerAPIModule, self).__init__(argument_spec=argument_spec, direct_params=direct_params,
error_callback=error_callback, warn_callback=warn_callback, **kwargs)
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)))

# If we have a specified tower config, load it
if self.params.get('tower_config_file'):
duplicated_params = [
fn for fn in self.AUTH_ARGSPEC
if fn != 'tower_config_file' and self.params.get(fn) is not None
]
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)
else:
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(msg='The config file {0} is not properly formatted'.format(config_file))

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:
try_config_parsing = True
if HAS_YAML:
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.")
try_config_parsing = False

except(AttributeError, yaml.YAMLError, AssertionError):
try_config_parsing = True

if try_config_parsing:
# 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]\n{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.short_params:
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.short_params:
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])

@staticmethod
def param_to_endpoint(name):
exceptions = {
Expand Down Expand Up @@ -650,13 +450,13 @@ def has_encrypted_values(obj):
"""
if isinstance(obj, dict):
for val in obj.values():
if TowerModule.has_encrypted_values(val):
if TowerAPIModule.has_encrypted_values(val):
return True
elif isinstance(obj, list):
for val in obj:
if TowerModule.has_encrypted_values(val):
if TowerAPIModule.has_encrypted_values(val):
return True
elif obj == TowerModule.ENCRYPTED_STRING:
elif obj == TowerAPIModule.ENCRYPTED_STRING:
return True
return False

Expand All @@ -678,10 +478,9 @@ def update_if_needed(self, existing_item, new_item, on_update=None, associations
# This will exit from the module on its own
# If 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
# This will return one of three things:
# This will return one of two 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
response = None
if existing_item:
Expand Down Expand Up @@ -777,25 +576,6 @@ def logout(self):
# 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()
if self.error_callback:
self.error_callback(**kwargs)
else:
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 warn(self, warning):
if self.warn_callback is not None:
self.warn_callback(warning)
else:
super(TowerModule, self).warn(warning)

def is_job_done(self, job_status):
if job_status in ['new', 'pending', 'waiting', 'running']:
return False
Expand Down
Loading

0 comments on commit cf116d1

Please sign in to comment.