diff options
Diffstat (limited to 'roles/lib_utils/library')
-rw-r--r-- | roles/lib_utils/library/iam_cert23.py | 314 | ||||
-rw-r--r-- | roles/lib_utils/library/oo_iam_kms.py | 172 | ||||
-rw-r--r-- | roles/lib_utils/library/repoquery.py | 32 | ||||
-rw-r--r-- | roles/lib_utils/library/yedit.py | 251 |
4 files changed, 685 insertions, 84 deletions
diff --git a/roles/lib_utils/library/iam_cert23.py b/roles/lib_utils/library/iam_cert23.py new file mode 100644 index 000000000..07b3d3bdf --- /dev/null +++ b/roles/lib_utils/library/iam_cert23.py @@ -0,0 +1,314 @@ +#!/usr/bin/python +# pylint: skip-file +# flake8: noqa +# This file is part of Ansible +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see <http://www.gnu.org/licenses/>. +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = ''' +--- +module: iam_cert +short_description: Manage server certificates for use on ELBs and CloudFront +description: + - Allows for the management of server certificates +version_added: "2.0" +options: + name: + description: + - Name of certificate to add, update or remove. + required: true + new_name: + description: + - When state is present, this will update the name of the cert. + - The cert, key and cert_chain parameters will be ignored if this is defined. + new_path: + description: + - When state is present, this will update the path of the cert. + - The cert, key and cert_chain parameters will be ignored if this is defined. + state: + description: + - Whether to create(or update) or delete certificate. + - If new_path or new_name is defined, specifying present will attempt to make an update these. + required: true + choices: [ "present", "absent" ] + path: + description: + - When creating or updating, specify the desired path of the certificate. + default: "/" + cert_chain: + description: + - The path to, or content of the CA certificate chain in PEM encoded format. + As of 2.4 content is accepted. If the parameter is not a file, it is assumed to be content. + cert: + description: + - The path to, or content of the certificate body in PEM encoded format. + As of 2.4 content is accepted. If the parameter is not a file, it is assumed to be content. + key: + description: + - The path to, or content of the private key in PEM encoded format. + As of 2.4 content is accepted. If the parameter is not a file, it is assumed to be content. + dup_ok: + description: + - By default the module will not upload a certificate that is already uploaded into AWS. + If set to True, it will upload the certificate as long as the name is unique. + default: False + + +requirements: [ "boto" ] +author: Jonathan I. Davila +extends_documentation_fragment: + - aws + - ec2 +''' + +EXAMPLES = ''' +# Basic server certificate upload from local file +- iam_cert: + name: very_ssl + state: present + cert: "{{ lookup('file', 'path/to/cert') }}" + key: "{{ lookup('file', 'path/to/key') }}" + cert_chain: "{{ lookup('file', 'path/to/certchain') }}" + +# Basic server certificate upload +- iam_cert: + name: very_ssl + state: present + cert: path/to/cert + key: path/to/key + cert_chain: path/to/certchain + +# Server certificate upload using key string +- iam_cert: + name: very_ssl + state: present + path: "/a/cert/path/" + cert: body_of_somecert + key: vault_body_of_privcertkey + cert_chain: body_of_myverytrustedchain + +# Basic rename of existing certificate +- iam_cert: + name: very_ssl + new_name: new_very_ssl + state: present + +''' +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.ec2 import ec2_argument_spec, get_aws_connection_info, connect_to_aws +import os + +try: + import boto + import boto.iam + import boto.ec2 + HAS_BOTO = True +except ImportError: + HAS_BOTO = False + + +def boto_exception(err): + '''generic error message handler''' + if hasattr(err, 'error_message'): + error = err.error_message + elif hasattr(err, 'message'): + error = err.message + else: + error = '%s: %s' % (Exception, err) + + return error + + +def cert_meta(iam, name): + certificate = iam.get_server_certificate(name).get_server_certificate_result.server_certificate + ocert = certificate.certificate_body + opath = certificate.server_certificate_metadata.path + ocert_id = certificate.server_certificate_metadata.server_certificate_id + upload_date = certificate.server_certificate_metadata.upload_date + exp = certificate.server_certificate_metadata.expiration + arn = certificate.server_certificate_metadata.arn + return opath, ocert, ocert_id, upload_date, exp, arn + + +def dup_check(module, iam, name, new_name, cert, orig_cert_names, orig_cert_bodies, dup_ok): + update = False + + # IAM cert names are case insensitive + names_lower = [n.lower() for n in [name, new_name] if n is not None] + orig_cert_names_lower = [ocn.lower() for ocn in orig_cert_names] + + if any(ct in orig_cert_names_lower for ct in names_lower): + for i_name in names_lower: + if cert is not None: + try: + c_index = orig_cert_names_lower.index(i_name) + except NameError: + continue + else: + # NOTE: remove the carriage return to strictly compare the cert bodies. + slug_cert = cert.replace('\r', '') + slug_orig_cert_bodies = orig_cert_bodies[c_index].replace('\r', '') + if slug_orig_cert_bodies == slug_cert: + update = True + break + elif slug_cert.startswith(slug_orig_cert_bodies): + update = True + break + elif slug_orig_cert_bodies != slug_cert: + module.fail_json(changed=False, msg='A cert with the name %s already exists and' + ' has a different certificate body associated' + ' with it. Certificates cannot have the same name' % orig_cert_names[c_index]) + else: + update = True + break + elif cert in orig_cert_bodies and not dup_ok: + for crt_name, crt_body in zip(orig_cert_names, orig_cert_bodies): + if crt_body == cert: + module.fail_json(changed=False, msg='This certificate already' + ' exists under the name %s' % crt_name) + + return update + + +def cert_action(module, iam, name, cpath, new_name, new_path, state, + cert, key, cert_chain, orig_cert_names, orig_cert_bodies, dup_ok): + if state == 'present': + update = dup_check(module, iam, name, new_name, cert, orig_cert_names, + orig_cert_bodies, dup_ok) + if update: + opath, ocert, ocert_id, upload_date, exp, arn = cert_meta(iam, name) + changed = True + if new_name and new_path: + iam.update_server_cert(name, new_cert_name=new_name, new_path=new_path) + module.exit_json(changed=changed, original_name=name, new_name=new_name, + original_path=opath, new_path=new_path, cert_body=ocert, + upload_date=upload_date, expiration_date=exp, arn=arn) + elif new_name and not new_path: + iam.update_server_cert(name, new_cert_name=new_name) + module.exit_json(changed=changed, original_name=name, new_name=new_name, + cert_path=opath, cert_body=ocert, + upload_date=upload_date, expiration_date=exp, arn=arn) + elif not new_name and new_path: + iam.update_server_cert(name, new_path=new_path) + module.exit_json(changed=changed, name=new_name, + original_path=opath, new_path=new_path, cert_body=ocert, + upload_date=upload_date, expiration_date=exp, arn=arn) + else: + changed = False + module.exit_json(changed=changed, name=name, cert_path=opath, cert_body=ocert, + upload_date=upload_date, expiration_date=exp, arn=arn, + msg='No new path or name specified. No changes made') + else: + changed = True + iam.upload_server_cert(name, cert, key, cert_chain=cert_chain, path=cpath) + opath, ocert, ocert_id, upload_date, exp, arn = cert_meta(iam, name) + module.exit_json(changed=changed, name=name, cert_path=opath, cert_body=ocert, + upload_date=upload_date, expiration_date=exp, arn=arn) + elif state == 'absent': + if name in orig_cert_names: + changed = True + iam.delete_server_cert(name) + module.exit_json(changed=changed, deleted_cert=name) + else: + changed = False + module.exit_json(changed=changed, msg='Certificate with the name %s already absent' % name) + + +def load_data(cert, key, cert_chain): + # if paths are provided rather than lookups read the files and return the contents + if cert and os.path.isfile(cert): + cert = open(cert, 'r').read().rstrip() + if key and os.path.isfile(key): + key = open(key, 'r').read().rstrip() + if cert_chain and os.path.isfile(cert_chain): + cert_chain = open(cert_chain, 'r').read() + return cert, key, cert_chain + + +def main(): + argument_spec = ec2_argument_spec() + argument_spec.update(dict( + state=dict(required=True, choices=['present', 'absent']), + name=dict(), + cert=dict(), + key=dict(no_log=True), + cert_chain=dict(), + new_name=dict(), + path=dict(default='/'), + new_path=dict(), + dup_ok=dict(type='bool') + ) + ) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[ + ['new_path', 'key'], + ['new_path', 'cert'], + ['new_path', 'cert_chain'], + ['new_name', 'key'], + ['new_name', 'cert'], + ['new_name', 'cert_chain'], + ], + ) + + if not HAS_BOTO: + module.fail_json(msg="Boto is required for this module") + + region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module) + + try: + if region: + iam = connect_to_aws(boto.iam, region, **aws_connect_kwargs) + else: + iam = boto.iam.connection.IAMConnection(**aws_connect_kwargs) + except boto.exception.NoAuthHandlerFound as e: + module.fail_json(msg=str(e)) + + state = module.params.get('state') + name = module.params.get('name') + path = module.params.get('path') + new_name = module.params.get('new_name') + new_path = module.params.get('new_path') + dup_ok = module.params.get('dup_ok') + if state == 'present' and not new_name and not new_path: + cert, key, cert_chain = load_data(cert=module.params.get('cert'), + key=module.params.get('key'), + cert_chain=module.params.get('cert_chain')) + else: + cert = key = cert_chain = None + + orig_cert_names = [ctb['server_certificate_name'] for ctb in + iam.get_all_server_certs().list_server_certificates_result.server_certificate_metadata_list] + orig_cert_bodies = [iam.get_server_certificate(thing).get_server_certificate_result.certificate_body + for thing in orig_cert_names] + if new_name == name: + new_name = None + if new_path == path: + new_path = None + + changed = False + try: + cert_action(module, iam, name, path, new_name, new_path, state, + cert, key, cert_chain, orig_cert_names, orig_cert_bodies, dup_ok) + except boto.exception.BotoServerError as err: + module.fail_json(changed=changed, msg=str(err), debug=[cert, key]) + + +if __name__ == '__main__': + main() diff --git a/roles/lib_utils/library/oo_iam_kms.py b/roles/lib_utils/library/oo_iam_kms.py new file mode 100644 index 000000000..c85745f01 --- /dev/null +++ b/roles/lib_utils/library/oo_iam_kms.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +''' +ansible module for creating AWS IAM KMS keys +''' +# vim: expandtab:tabstop=4:shiftwidth=4 +# +# AWS IAM KMS ansible module +# +# +# Copyright 2016 Red Hat Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# Jenkins environment doesn't have all the required libraries +# pylint: disable=import-error +import time +import boto3 +# Ansible modules need this wildcard import +# pylint: disable=unused-wildcard-import, wildcard-import, redefined-builtin +from ansible.module_utils.basic import AnsibleModule + +AWS_ALIAS_URL = "http://docs.aws.amazon.com/kms/latest/developerguide/programming-aliases.html" + + +class AwsIamKms(object): + ''' + ansible module for AWS IAM KMS + ''' + + def __init__(self): + ''' constructor ''' + self.module = None + self.kms_client = None + self.aliases = None + + @staticmethod + def valid_alias_name(user_alias): + ''' AWS KMS aliases must start with 'alias/' ''' + valid_start = 'alias/' + if user_alias.startswith(valid_start): + return True + + return False + + def get_all_kms_info(self): + '''fetch all kms info and return them + + list_keys doesn't have information regarding aliases + list_aliases doesn't have the full kms arn + + fetch both and join them on the targetKeyId + ''' + aliases = self.kms_client.list_aliases()['Aliases'] + keys = self.kms_client.list_keys()['Keys'] + + for alias in aliases: + for key in keys: + if 'TargetKeyId' in alias and 'KeyId' in key: + if alias['TargetKeyId'] == key['KeyId']: + alias.update(key) + + return aliases + + def get_kms_entry(self, user_alias, alias_list): + ''' return single alias details from list of aliases ''' + for alias in alias_list: + if user_alias == alias.get('AliasName', False): + return alias + + msg = "Did not find alias {}".format(user_alias) + self.module.exit_json(failed=True, results=msg) + + @staticmethod + def exists(user_alias, alias_list): + ''' Check if KMS alias already exists ''' + for alias in alias_list: + if user_alias == alias.get('AliasName'): + return True + + return False + + def main(self): + ''' entry point for module ''' + + self.module = AnsibleModule( + argument_spec=dict( + state=dict(default='list', choices=['list', 'present'], type='str'), + region=dict(default=None, required=True, type='str'), + alias=dict(default=None, type='str'), + # description default cannot be None + description=dict(default='', type='str'), + aws_access_key=dict(default=None, type='str'), + aws_secret_key=dict(default=None, type='str'), + ), + ) + + state = self.module.params['state'] + aws_access_key = self.module.params['aws_access_key'] + aws_secret_key = self.module.params['aws_secret_key'] + if aws_access_key and aws_secret_key: + boto3.setup_default_session(aws_access_key_id=aws_access_key, + aws_secret_access_key=aws_secret_key, + region_name=self.module.params['region']) + else: + boto3.setup_default_session(region_name=self.module.params['region']) + + self.kms_client = boto3.client('kms') + + aliases = self.get_all_kms_info() + + if state == 'list': + if self.module.params['alias'] is not None: + user_kms = self.get_kms_entry(self.module.params['alias'], + aliases) + self.module.exit_json(changed=False, results=user_kms, + state="list") + else: + self.module.exit_json(changed=False, results=aliases, + state="list") + + if state == 'present': + + # early sanity check to make sure the alias name conforms with + # AWS alias name requirements + if not self.valid_alias_name(self.module.params['alias']): + self.module.exit_json(failed=True, changed=False, + results="Alias must start with the prefix " + + "'alias/'. Please see " + AWS_ALIAS_URL, + state='present') + + if not self.exists(self.module.params['alias'], aliases): + # if we didn't find it, create it + response = self.kms_client.create_key(KeyUsage='ENCRYPT_DECRYPT', + Description=self.module.params['description']) + kid = response['KeyMetadata']['KeyId'] + response = self.kms_client.create_alias(AliasName=self.module.params['alias'], + TargetKeyId=kid) + # sleep for a bit so that the KMS data can be queried + time.sleep(10) + # get details for newly created KMS entry + new_alias_list = self.kms_client.list_aliases()['Aliases'] + user_kms = self.get_kms_entry(self.module.params['alias'], + new_alias_list) + + self.module.exit_json(changed=True, results=user_kms, + state='present') + + # already exists, normally we would check whether we need to update it + # but this module isn't written to allow changing the alias name + # or changing whether the key is enabled/disabled + user_kms = self.get_kms_entry(self.module.params['alias'], aliases) + self.module.exit_json(changed=False, results=user_kms, + state="present") + + self.module.exit_json(failed=True, + changed=False, + results='Unknown state passed. %s' % state, + state="unknown") + + +if __name__ == '__main__': + AwsIamKms().main() diff --git a/roles/lib_utils/library/repoquery.py b/roles/lib_utils/library/repoquery.py index ee98470b0..95a305b58 100644 --- a/roles/lib_utils/library/repoquery.py +++ b/roles/lib_utils/library/repoquery.py @@ -34,6 +34,7 @@ import json # noqa: F401 import os # noqa: F401 import re # noqa: F401 import shutil # noqa: F401 +import tempfile # noqa: F401 try: import ruamel.yaml as yaml # noqa: F401 @@ -421,15 +422,16 @@ class RepoqueryCLI(object): class Repoquery(RepoqueryCLI): ''' Class to wrap the repoquery ''' - # pylint: disable=too-many-arguments + # pylint: disable=too-many-arguments,too-many-instance-attributes def __init__(self, name, query_type, show_duplicates, - match_version, verbose): + match_version, ignore_excluders, verbose): ''' Constructor for YumList ''' super(Repoquery, self).__init__(None) self.name = name self.query_type = query_type self.show_duplicates = show_duplicates self.match_version = match_version + self.ignore_excluders = ignore_excluders self.verbose = verbose if self.match_version: @@ -437,6 +439,8 @@ class Repoquery(RepoqueryCLI): self.query_format = "%{version}|%{release}|%{arch}|%{repo}|%{version}-%{release}" + self.tmp_file = None + def build_cmd(self): ''' build the repoquery cmd options ''' @@ -448,6 +452,9 @@ class Repoquery(RepoqueryCLI): if self.show_duplicates: repo_cmd.append('--show-duplicates') + if self.ignore_excluders: + repo_cmd.append('--config=' + self.tmp_file.name) + repo_cmd.append(self.name) return repo_cmd @@ -458,7 +465,7 @@ class Repoquery(RepoqueryCLI): version_dict = defaultdict(dict) - for version in query_output.split('\n'): + for version in query_output.decode().split('\n'): pkg_info = version.split("|") pkg_version = {} @@ -519,6 +526,20 @@ class Repoquery(RepoqueryCLI): def repoquery(self): '''perform a repoquery ''' + if self.ignore_excluders: + # Duplicate yum.conf and reset exclude= line to an empty string + # to clear a list of all excluded packages + self.tmp_file = tempfile.NamedTemporaryFile() + + with open("/etc/yum.conf", "r") as file_handler: + yum_conf_lines = file_handler.readlines() + + yum_conf_lines = ["exclude=" if l.startswith("exclude=") else l for l in yum_conf_lines] + + with open(self.tmp_file.name, "w") as file_handler: + file_handler.writelines(yum_conf_lines) + file_handler.flush() + repoquery_cmd = self.build_cmd() rval = self._repoquery_cmd(repoquery_cmd, True, 'raw') @@ -541,6 +562,9 @@ class Repoquery(RepoqueryCLI): else: rval['package_found'] = False + if self.ignore_excluders: + self.tmp_file.close() + return rval @staticmethod @@ -552,6 +576,7 @@ class Repoquery(RepoqueryCLI): params['query_type'], params['show_duplicates'], params['match_version'], + params['ignore_excluders'], params['verbose'], ) @@ -592,6 +617,7 @@ def main(): verbose=dict(default=False, required=False, type='bool'), show_duplicates=dict(default=False, required=False, type='bool'), match_version=dict(default=None, required=False, type='str'), + ignore_excluders=dict(default=False, required=False, type='bool'), ), supports_check_mode=False, required_if=[('show_duplicates', True, ['name'])], diff --git a/roles/lib_utils/library/yedit.py b/roles/lib_utils/library/yedit.py index b1d9d6869..baf72fe47 100644 --- a/roles/lib_utils/library/yedit.py +++ b/roles/lib_utils/library/yedit.py @@ -34,6 +34,7 @@ import json # noqa: F401 import os # noqa: F401 import re # noqa: F401 import shutil # noqa: F401 +import tempfile # noqa: F401 try: import ruamel.yaml as yaml # noqa: F401 @@ -180,13 +181,27 @@ EXAMPLES = ''' # a: # b: # c: d +# +# multiple edits at the same time +- name: perform multiple edits + yedit: + src: somefile.yml + edits: + - key: a#b#c + value: d + - key: a#b#c#d + value: e + state: present +# Results: +# a: +# b: +# c: +# d: e ''' # -*- -*- -*- End included fragment: doc/yedit -*- -*- -*- # -*- -*- -*- Begin included fragment: class/yedit.py -*- -*- -*- -# pylint: disable=undefined-variable,missing-docstring -# noqa: E301,E302 class YeditException(Exception): @@ -198,7 +213,7 @@ class YeditException(Exception): class Yedit(object): ''' Class to modify yaml files ''' re_valid_key = r"(((\[-?\d+\])|([0-9a-zA-Z%s/_-]+)).?)+$" - re_key = r"(?:\[(-?\d+)\])|([0-9a-zA-Z%s/_-]+)" + re_key = r"(?:\[(-?\d+)\])|([0-9a-zA-Z{}/_-]+)" com_sep = set(['.', '#', '|', ':']) # pylint: disable=too-many-arguments @@ -220,13 +235,13 @@ class Yedit(object): @property def separator(self): - ''' getter method for yaml_dict ''' + ''' getter method for separator ''' return self._separator @separator.setter - def separator(self): - ''' getter method for yaml_dict ''' - return self._separator + def separator(self, inc_sep): + ''' setter method for separator ''' + self._separator = inc_sep @property def yaml_dict(self): @@ -242,13 +257,13 @@ class Yedit(object): def parse_key(key, sep='.'): '''parse the key allowing the appropriate separator''' common_separators = list(Yedit.com_sep - set([sep])) - return re.findall(Yedit.re_key % ''.join(common_separators), key) + return re.findall(Yedit.re_key.format(''.join(common_separators)), key) @staticmethod def valid_key(key, sep='.'): '''validate the incoming key''' common_separators = list(Yedit.com_sep - set([sep])) - if not re.match(Yedit.re_valid_key % ''.join(common_separators), key): + if not re.match(Yedit.re_valid_key.format(''.join(common_separators)), key): return False return True @@ -270,7 +285,7 @@ class Yedit(object): key_indexes = Yedit.parse_key(key, sep) for arr_ind, dict_key in key_indexes[:-1]: if dict_key and isinstance(data, dict): - data = data.get(dict_key, None) + data = data.get(dict_key) elif (arr_ind and isinstance(data, list) and int(arr_ind) <= len(data) - 1): data = data[int(arr_ind)] @@ -311,7 +326,8 @@ class Yedit(object): continue elif data and not isinstance(data, dict): - return None + raise YeditException("Unexpected item type found while going through key " + + "path: {} (at key: {})".format(key, dict_key)) data[dict_key] = {} data = data[dict_key] @@ -320,7 +336,7 @@ class Yedit(object): int(arr_ind) <= len(data) - 1): data = data[int(arr_ind)] else: - return None + raise YeditException("Unexpected item type found while going through key path: {}".format(key)) if key == '': data = item @@ -334,6 +350,12 @@ class Yedit(object): elif key_indexes[-1][1] and isinstance(data, dict): data[key_indexes[-1][1]] = item + # didn't add/update to an existing list, nor add/update key to a dict + # so we must have been provided some syntax like a.b.c[<int>] = "data" for a + # non-existent array + else: + raise YeditException("Error adding to object at path: {}".format(key)) + return data @staticmethod @@ -352,7 +374,7 @@ class Yedit(object): key_indexes = Yedit.parse_key(key, sep) for arr_ind, dict_key in key_indexes: if dict_key and isinstance(data, dict): - data = data.get(dict_key, None) + data = data.get(dict_key) elif (arr_ind and isinstance(data, list) and int(arr_ind) <= len(data) - 1): data = data[int(arr_ind)] @@ -452,7 +474,7 @@ class Yedit(object): self.yaml_dict = json.loads(contents) except yaml.YAMLError as err: # Error loading yaml or json - raise YeditException('Problem with loading yaml file. %s' % err) + raise YeditException('Problem with loading yaml file. {}'.format(err)) return self.yaml_dict @@ -571,8 +593,8 @@ class Yedit(object): # AUDIT:maybe-no-member makes sense due to fuzzy types # pylint: disable=maybe-no-member if not isinstance(value, dict): - raise YeditException('Cannot replace key, value entry in ' + - 'dict with non-dict type. value=[%s] [%s]' % (value, type(value))) # noqa: E501 + raise YeditException('Cannot replace key, value entry in dict with non-dict type. ' + + 'value=[{}] type=[{}]'.format(value, type(value))) entry.update(value) return (True, self.yaml_dict) @@ -633,7 +655,17 @@ class Yedit(object): pass result = Yedit.add_entry(tmp_copy, path, value, self.separator) - if not result: + if result is None: + return (False, self.yaml_dict) + + # When path equals "" it is a special case. + # "" refers to the root of the document + # Only update the root path (entire document) when its a list or dict + if path == '': + if isinstance(result, list) or isinstance(result, dict): + self.yaml_dict = result + return (True, self.yaml_dict) + return (False, self.yaml_dict) self.yaml_dict = tmp_copy @@ -659,7 +691,7 @@ class Yedit(object): pass result = Yedit.add_entry(tmp_copy, path, value, self.separator) - if result: + if result is not None: self.yaml_dict = tmp_copy return (True, self.yaml_dict) @@ -691,114 +723,149 @@ class Yedit(object): # we will convert to bool if it matches any of the above cases if isinstance(inc_value, str) and 'bool' in vtype: if inc_value not in true_bools and inc_value not in false_bools: - raise YeditException('Not a boolean type. str=[%s] vtype=[%s]' - % (inc_value, vtype)) + raise YeditException('Not a boolean type. str=[{}] vtype=[{}]'.format(inc_value, vtype)) elif isinstance(inc_value, bool) and 'str' in vtype: inc_value = str(inc_value) + # There is a special case where '' will turn into None after yaml loading it so skip + if isinstance(inc_value, str) and inc_value == '': + pass # If vtype is not str then go ahead and attempt to yaml load it. - if isinstance(inc_value, str) and 'str' not in vtype: + elif isinstance(inc_value, str) and 'str' not in vtype: try: - inc_value = yaml.load(inc_value) + inc_value = yaml.safe_load(inc_value) except Exception: - raise YeditException('Could not determine type of incoming ' + - 'value. value=[%s] vtype=[%s]' - % (type(inc_value), vtype)) + raise YeditException('Could not determine type of incoming value. ' + + 'value=[{}] vtype=[{}]'.format(type(inc_value), vtype)) return inc_value + @staticmethod + def process_edits(edits, yamlfile): + '''run through a list of edits and process them one-by-one''' + results = [] + for edit in edits: + value = Yedit.parse_value(edit['value'], edit.get('value_type', '')) + if edit.get('action') == 'update': + # pylint: disable=line-too-long + curr_value = Yedit.get_curr_value( + Yedit.parse_value(edit.get('curr_value')), + edit.get('curr_value_format')) + + rval = yamlfile.update(edit['key'], + value, + edit.get('index'), + curr_value) + + elif edit.get('action') == 'append': + rval = yamlfile.append(edit['key'], value) + + else: + rval = yamlfile.put(edit['key'], value) + + if rval[0]: + results.append({'key': edit['key'], 'edit': rval[1]}) + + return {'changed': len(results) > 0, 'results': results} + # pylint: disable=too-many-return-statements,too-many-branches @staticmethod - def run_ansible(module): + def run_ansible(params): '''perform the idempotent crud operations''' - yamlfile = Yedit(filename=module.params['src'], - backup=module.params['backup'], - separator=module.params['separator']) + yamlfile = Yedit(filename=params['src'], + backup=params['backup'], + separator=params['separator']) + + state = params['state'] - if module.params['src']: + if params['src']: rval = yamlfile.load() - if yamlfile.yaml_dict is None and \ - module.params['state'] != 'present': + if yamlfile.yaml_dict is None and state != 'present': return {'failed': True, - 'msg': 'Error opening file [%s]. Verify that the ' + - 'file exists, that it is has correct' + - ' permissions, and is valid yaml.'} - - if module.params['state'] == 'list': - if module.params['content']: - content = Yedit.parse_value(module.params['content'], - module.params['content_type']) + 'msg': 'Error opening file [{}]. Verify that the '.format(params['src']) + + 'file exists, that it is has correct permissions, and is valid yaml.'} + + if state == 'list': + if params['content']: + content = Yedit.parse_value(params['content'], params['content_type']) yamlfile.yaml_dict = content - if module.params['key']: - rval = yamlfile.get(module.params['key']) or {} + if params['key']: + rval = yamlfile.get(params['key']) or {} - return {'changed': False, 'result': rval, 'state': "list"} + return {'changed': False, 'result': rval, 'state': state} - elif module.params['state'] == 'absent': - if module.params['content']: - content = Yedit.parse_value(module.params['content'], - module.params['content_type']) + elif state == 'absent': + if params['content']: + content = Yedit.parse_value(params['content'], params['content_type']) yamlfile.yaml_dict = content - if module.params['update']: - rval = yamlfile.pop(module.params['key'], - module.params['value']) + if params['update']: + rval = yamlfile.pop(params['key'], params['value']) else: - rval = yamlfile.delete(module.params['key']) + rval = yamlfile.delete(params['key']) - if rval[0] and module.params['src']: + if rval[0] and params['src']: yamlfile.write() - return {'changed': rval[0], 'result': rval[1], 'state': "absent"} + return {'changed': rval[0], 'result': rval[1], 'state': state} - elif module.params['state'] == 'present': + elif state == 'present': # check if content is different than what is in the file - if module.params['content']: - content = Yedit.parse_value(module.params['content'], - module.params['content_type']) + if params['content']: + content = Yedit.parse_value(params['content'], params['content_type']) # We had no edits to make and the contents are the same if yamlfile.yaml_dict == content and \ - module.params['value'] is None: - return {'changed': False, - 'result': yamlfile.yaml_dict, - 'state': "present"} + params['value'] is None: + return {'changed': False, 'result': yamlfile.yaml_dict, 'state': state} yamlfile.yaml_dict = content - # we were passed a value; parse it - if module.params['value']: - value = Yedit.parse_value(module.params['value'], - module.params['value_type']) - key = module.params['key'] - if module.params['update']: - # pylint: disable=line-too-long - curr_value = Yedit.get_curr_value(Yedit.parse_value(module.params['curr_value']), # noqa: E501 - module.params['curr_value_format']) # noqa: E501 + # If we were passed a key, value then + # we enapsulate it in a list and process it + # Key, Value passed to the module : Converted to Edits list # + edits = [] + _edit = {} + if params['value'] is not None: + _edit['value'] = params['value'] + _edit['value_type'] = params['value_type'] + _edit['key'] = params['key'] - rval = yamlfile.update(key, value, module.params['index'], curr_value) # noqa: E501 + if params['update']: + _edit['action'] = 'update' + _edit['curr_value'] = params['curr_value'] + _edit['curr_value_format'] = params['curr_value_format'] + _edit['index'] = params['index'] - elif module.params['append']: - rval = yamlfile.append(key, value) - else: - rval = yamlfile.put(key, value) + elif params['append']: + _edit['action'] = 'append' - if rval[0] and module.params['src']: + edits.append(_edit) + + elif params['edits'] is not None: + edits = params['edits'] + + if edits: + results = Yedit.process_edits(edits, yamlfile) + + # if there were changes and a src provided to us we need to write + if results['changed'] and params['src']: yamlfile.write() - return {'changed': rval[0], - 'result': rval[1], 'state': "present"} + return {'changed': results['changed'], 'result': results['results'], 'state': state} # no edits to make - if module.params['src']: + if params['src']: # pylint: disable=redefined-variable-type rval = yamlfile.write() return {'changed': rval[0], 'result': rval[1], - 'state': "present"} + 'state': state} + # We were passed content but no src, key or value, or edits. Return contents in memory + return {'changed': False, 'result': yamlfile.yaml_dict, 'state': state} return {'failed': True, 'msg': 'Unkown state passed'} # -*- -*- -*- End included fragment: class/yedit.py -*- -*- -*- @@ -830,12 +897,34 @@ def main(): type='str'), backup=dict(default=True, type='bool'), separator=dict(default='.', type='str'), + edits=dict(default=None, type='list'), ), mutually_exclusive=[["curr_value", "index"], ['update', "append"]], required_one_of=[["content", "src"]], ) - rval = Yedit.run_ansible(module) + # Verify we recieved either a valid key or edits with valid keys when receiving a src file. + # A valid key being not None or not ''. + if module.params['src'] is not None: + key_error = False + edit_error = False + + if module.params['key'] in [None, '']: + key_error = True + + if module.params['edits'] in [None, []]: + edit_error = True + + else: + for edit in module.params['edits']: + if edit.get('key') in [None, '']: + edit_error = True + break + + if key_error and edit_error: + module.fail_json(failed=True, msg='Empty value for parameter key not allowed.') + + rval = Yedit.run_ansible(module.params) if 'failed' in rval and rval['failed']: module.fail_json(**rval) |