From a135c483ceea5ae2d56a953ce4b80189a7d9b0b0 Mon Sep 17 00:00:00 2001 From: n3pjk Date: Sat, 13 Jul 2019 11:04:36 -0400 Subject: [PATCH] Add OAuth and Multi-Record Query for SNOW (#58410) * Add SNOW OAuth Support and Multi-record Query * Add OAuth documentation to snow_record_find * Fix lint and verification issue for PR 58410 * Fix E309 and E324 errors in PR 58410 * Fix E307, need advice on E309 * Fix E309 for PR 58410 * Re-add instance, username and password documentation * Fix data type mismatch in documentation * Remove doc_fragment overlap * Refactor service now module space Signed-off-by: Abhijeet Kasurde --- lib/ansible/module_utils/service_now.py | 94 ++++++ .../modules/notification/snow_record.py | 154 +++++----- .../modules/notification/snow_record_find.py | 277 ++++++++++++++++++ .../plugins/doc_fragments/service_now.py | 40 +++ test/sanity/validate-modules/ignore.txt | 2 - 5 files changed, 489 insertions(+), 78 deletions(-) create mode 100644 lib/ansible/module_utils/service_now.py create mode 100644 lib/ansible/modules/notification/snow_record_find.py create mode 100644 lib/ansible/plugins/doc_fragments/service_now.py diff --git a/lib/ansible/module_utils/service_now.py b/lib/ansible/module_utils/service_now.py new file mode 100644 index 00000000000..ab395ebcddd --- /dev/null +++ b/lib/ansible/module_utils/service_now.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Ansible Project +# Copyright: (c) 2017, Tim Rightnour +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import traceback +from ansible.module_utils.basic import missing_required_lib + +# Pull in pysnow +HAS_PYSNOW = False +PYSNOW_IMP_ERR = None +try: + import pysnow + HAS_PYSNOW = True +except ImportError: + PYSNOW_IMP_ERR = traceback.format_exc() + + +class ServiceNowClient(object): + def __init__(self, module): + """ + Constructor + """ + if not HAS_PYSNOW: + module.fail_json(msg=missing_required_lib('pysnow'), exception=PYSNOW_IMP_ERR) + + self.module = module + self.params = module.params + self.client_id = self.params['client_id'] + self.client_secret = self.params['client_secret'] + self.username = self.params['username'] + self.password = self.params['password'] + self.instance = self.params['instance'] + self.session = {'token': None} + self.conn = None + + def login(self): + result = dict( + changed=False + ) + + if self.params['client_id'] is not None: + try: + self.conn = pysnow.OAuthClient(client_id=self.client_id, + client_secret=self.client_secret, + token_updater=self.updater, + instance=self.instance) + except Exception as detail: + self.module.fail_json(msg='Could not connect to ServiceNow: {0}'.format(str(detail)), **result) + if not self.session['token']: + # No previous token exists, Generate new. + try: + self.session['token'] = self.conn.generate_token(self.username, self.password) + except pysnow.exceptions.TokenCreateError as detail: + self.module.fail_json(msg='Unable to generate a new token: {0}'.format(str(detail)), **result) + + self.conn.set_token(self.session['token']) + elif self.username is not None: + try: + self.conn = pysnow.Client(instance=self.instance, + user=self.username, + password=self.password) + except Exception as detail: + self.module.fail_json(msg='Could not connect to ServiceNow: {0}'.format(str(detail)), **result) + else: + snow_error = "Must specify username/password or client_id/client_secret" + self.module.fail_json(msg=snow_error, **result) + + def updater(self, new_token): + self.session['token'] = new_token + self.conn = pysnow.OAuthClient(client_id=self.client_id, + client_secret=self.client_secret, + token_updater=self.updater, + instance=self.instance) + try: + self.conn.set_token(self.session['token']) + except pysnow.exceptions.MissingToken: + snow_error = "Token is missing" + self.module.fail_json(msg=snow_error) + except Exception as detail: + self.module.fail_json(msg='Could not refresh token: {0}'.format(str(detail))) + + @staticmethod + def snow_argument_spec(): + return dict( + instance=dict(type='str', required=True), + username=dict(type='str', required=True, no_log=True), + password=dict(type='str', required=True, no_log=True), + client_id=dict(type='str', no_log=True), + client_secret=dict(type='str', no_log=True), + ) diff --git a/lib/ansible/modules/notification/snow_record.py b/lib/ansible/modules/notification/snow_record.py index d0c9ef9d051..c94b3cdf20c 100644 --- a/lib/ansible/modules/notification/snow_record.py +++ b/lib/ansible/modules/notification/snow_record.py @@ -1,5 +1,5 @@ #!/usr/bin/python -# Copyright (c) 2017 Tim Rightnour +# Copyright: (c) 2017, Tim Rightnour # 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 @@ -14,63 +14,64 @@ ANSIBLE_METADATA = { DOCUMENTATION = ''' --- module: snow_record - -short_description: Create/Delete/Update records in ServiceNow - +short_description: Manage records in ServiceNow version_added: "2.5" - description: - - Creates/Deletes/Updates a single record in ServiceNow - + - Creates, deletes and updates a single record in ServiceNow. options: - instance: - description: - - The service now instance name - required: true - username: - description: - - User to connect to ServiceNow as - required: true - password: - description: - - Password for username - required: true table: - description: - - Table to query for records - required: false - default: incident + description: + - Table to query for records. + required: false + default: incident + type: str state: - description: - - If C(present) is supplied with a C(number) - argument, the module will attempt to update the record with - the supplied data. If no such record exists, a new one will - be created. C(absent) will delete a record. - choices: [ present, absent ] - required: true + description: + - If C(present) is supplied with a C(number) argument, the module will attempt to update the record with the supplied data. + - If no such record exists, a new one will be created. + - C(absent) will delete a record. + choices: [ present, absent ] + required: true + type: str data: - description: - - key, value pairs of data to load into the record. - See Examples. Required for C(state:present) + description: + - key, value pairs of data to load into the record. See Examples. + - Required for C(state:present). + type: dict number: - description: - - Record number to update. Required for C(state:absent) - required: false + description: + - Record number to update. + - Required for C(state:absent). + required: false + type: str lookup_field: - description: - - Changes the field that C(number) uses to find records - required: false - default: number + description: + - Changes the field that C(number) uses to find records. + required: false + default: number + type: str attachment: - description: - - Attach a file to the record - required: false - + description: + - Attach a file to the record. + required: false + type: str + client_id: + description: + - Client ID generated by ServiceNow. + required: false + type: str + version_added: "2.9" + client_secret: + description: + - Client Secret associated with client id. + required: false + type: str + version_added: "2.9" requirements: - python pysnow (pysnow) - author: - Tim Rightnour (@garbled1) +extends_documentation_fragment: service_now.documentation ''' EXAMPLES = ''' @@ -84,6 +85,18 @@ EXAMPLES = ''' table: sys_user lookup_field: sys_id +- name: Grab a user record using OAuth + snow_record: + username: ansible_test + password: my_password + client_id: "1234567890abcdef1234567890abcdef" + client_secret: "Password1!" + instance: dev99999 + state: present + number: 62826bf03710200044e0bfc8bcbe5df1 + table: sys_user + lookup_field: sys_id + - name: Create an incident snow_record: username: ansible_test @@ -146,29 +159,23 @@ attached_file: ''' import os -import traceback -from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_bytes, to_native +from ansible.module_utils.service_now import ServiceNowClient -# Pull in pysnow -HAS_PYSNOW = False -PYSNOW_IMP_ERR = None try: + # This is being handled by ServiceNowClient import pysnow - HAS_PYSNOW = True - except ImportError: - PYSNOW_IMP_ERR = traceback.format_exc() + pass def run_module(): # define the available arguments/parameters that a user can pass to # the module - module_args = dict( - instance=dict(default=None, type='str', required=True), - username=dict(default=None, type='str', required=True, no_log=True), - password=dict(default=None, type='str', required=True, no_log=True), + module_args = ServiceNowClient.snow_argument_spec() + module_args.update( table=dict(type='str', required=False, default='incident'), state=dict(choices=['present', 'absent'], type='str', required=True), @@ -177,6 +184,9 @@ def run_module(): lookup_field=dict(default='number', required=False, type='str'), attachment=dict(default=None, required=False, type='str') ) + module_required_together = [ + ['client_id', 'client_secret'] + ] module_required_if = [ ['state', 'absent', ['number']], ] @@ -184,17 +194,16 @@ def run_module(): module = AnsibleModule( argument_spec=module_args, supports_check_mode=True, + required_together=module_required_together, required_if=module_required_if ) - # check for pysnow - if not HAS_PYSNOW: - module.fail_json(msg=missing_required_lib('pysnow'), exception=PYSNOW_IMP_ERR) + # Connect to ServiceNow + service_now_client = ServiceNowClient(module) + conn = service_now_client.conn params = module.params instance = params['instance'] - username = params['username'] - password = params['password'] table = params['table'] state = params['state'] number = params['number'] @@ -219,13 +228,6 @@ def run_module(): else: attach = None - # Connect to ServiceNow - try: - conn = pysnow.Client(instance=instance, user=username, - password=password) - except Exception as detail: - module.fail_json(msg='Could not connect to ServiceNow: {0}'.format(str(detail)), **result) - # Deal with check mode if module.check_mode: @@ -245,7 +247,7 @@ def run_module(): except pysnow.exceptions.NoResults: result['record'] = None except Exception as detail: - module.fail_json(msg="Unknown failure in query record: {0}".format(str(detail)), **result) + module.fail_json(msg="Unknown failure in query record: {0}".format(to_native(detail)), **result) # Let's simulate modification else: @@ -260,7 +262,7 @@ def run_module(): snow_error = "Record does not exist" module.fail_json(msg=snow_error, **result) except Exception as detail: - module.fail_json(msg="Unknown failure in query record: {0}".format(str(detail)), **result) + module.fail_json(msg="Unknown failure in query record: {0}".format(to_native(detail)), **result) module.exit_json(**result) # now for the real thing: (non-check mode) @@ -269,7 +271,7 @@ def run_module(): if state == 'present' and number is None: try: record = conn.insert(table=table, payload=dict(data)) - except pysnow.UnexpectedResponse as e: + except pysnow.exceptions.UnexpectedResponseFormat as e: snow_error = "Failed to create record: {0}, details: {1}".format(e.error_summary, e.error_details) module.fail_json(msg=snow_error, **result) result['record'] = record @@ -285,11 +287,11 @@ def run_module(): except pysnow.exceptions.MultipleResults: snow_error = "Multiple record match" module.fail_json(msg=snow_error, **result) - except pysnow.UnexpectedResponse as e: + except pysnow.exceptions.UnexpectedResponseFormat as e: snow_error = "Failed to delete record: {0}, details: {1}".format(e.error_summary, e.error_details) module.fail_json(msg=snow_error, **result) except Exception as detail: - snow_error = "Failed to delete record: {0}".format(str(detail)) + snow_error = "Failed to delete record: {0}".format(to_native(detail)) module.fail_json(msg=snow_error, **result) result['record'] = res result['changed'] = True @@ -316,11 +318,11 @@ def run_module(): except pysnow.exceptions.NoResults: snow_error = "Record does not exist" module.fail_json(msg=snow_error, **result) - except pysnow.UnexpectedResponse as e: + except pysnow.exceptions.UnexpectedResponseFormat as e: snow_error = "Failed to update record: {0}, details: {1}".format(e.error_summary, e.error_details) module.fail_json(msg=snow_error, **result) except Exception as detail: - snow_error = "Failed to update record: {0}".format(str(detail)) + snow_error = "Failed to update record: {0}".format(to_native(detail)) module.fail_json(msg=snow_error, **result) module.exit_json(**result) diff --git a/lib/ansible/modules/notification/snow_record_find.py b/lib/ansible/modules/notification/snow_record_find.py new file mode 100644 index 00000000000..5ea03aa934f --- /dev/null +++ b/lib/ansible/modules/notification/snow_record_find.py @@ -0,0 +1,277 @@ +#!/usr/bin/python +# Copyright: (c) 2017, Tim Rightnour +# 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: snow_record_find +short_description: Search for multiple records from ServiceNow +version_added: "2.9" +description: + - Gets multiple records from a specified table from ServiceNow based on a query dictionary. +options: + table: + description: + - Table to query for records. + type: str + required: false + default: incident + query: + description: + - Dict to query for records. + type: dict + required: true + max_records: + description: + - Maximum number of records to return. + type: int + required: false + default: 20 + order_by: + description: + - Field to sort the results on. + - Can prefix with "-" or "+" to change decending or ascending sort order. + type: str + default: "-created_on" + required: false + return_fields: + description: + - Fields of the record to return in the json. + - By default, all fields will be returned. + type: list + required: false +requirements: + - python pysnow (pysnow) +author: + - Tim Rightnour (@garbled1) +extends_documentation_fragment: service_now.documentation +''' + +EXAMPLES = ''' +- name: Search for incident assigned to group, return specific fields + snow_record_find: + username: ansible_test + password: my_password + instance: dev99999 + table: incident + query: + assignment_group: d625dccec0a8016700a222a0f7900d06 + return_fields: + - number + - opened_at + +- name: Using OAuth, search for incident assigned to group, return specific fields + snow_record_find: + username: ansible_test + password: my_password + client_id: "1234567890abcdef1234567890abcdef" + client_secret: "Password1!" + instance: dev99999 + table: incident + query: + assignment_group: d625dccec0a8016700a222a0f7900d06 + return_fields: + - number + - opened_at + +- name: Find open standard changes with my template + snow_record_find: + username: ansible_test + password: my_password + instance: dev99999 + table: change_request + query: + AND: + equals: + active: "True" + type: "standard" + u_change_stage: "80" + contains: + u_template: "MY-Template" + return_fields: + - sys_id + - number + - sys_created_on + - sys_updated_on + - u_template + - active + - type + - u_change_stage + - sys_created_by + - description + - short_description +''' + +RETURN = ''' +record: + description: The full contents of the matching ServiceNow records as a list of records. + type: dict + returned: always +''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.service_now import ServiceNowClient +from ansible.module_utils._text import to_native + +try: + # This is being managed by ServiceNowClient + import pysnow +except ImportError: + pass + +# OAuth Variables +module = None +client_id = None +client_secret = None +instance = None +session = {'token': None} + + +class BuildQuery(object): + ''' + This is a BuildQuery manipulation class that constructs + a pysnow.QueryBuilder object based on data input. + ''' + + def __init__(self, module): + self.module = module + self.logic_operators = ["AND", "OR", "NQ"] + self.condition_operator = { + 'equals': self._condition_closure, + 'not_equals': self._condition_closure, + 'contains': self._condition_closure, + 'not_contains': self._condition_closure, + 'starts_with': self._condition_closure, + 'ends_with': self._condition_closure, + 'greater_than': self._condition_closure, + 'less_than': self._condition_closure, + } + self.accepted_cond_ops = self.condition_operator.keys() + self.append_operator = False + self.simple_query = True + self.data = module.params['query'] + + def _condition_closure(self, cond, query_field, query_value): + self.qb.field(query_field) + getattr(self.qb, cond)(query_value) + + def _iterate_fields(self, data, logic_op, cond_op): + if isinstance(data, dict): + for query_field, query_value in data.items(): + if self.append_operator: + getattr(self.qb, logic_op)() + self.condition_operator[cond_op](cond_op, query_field, query_value) + self.append_operator = True + else: + self.module.fail_json(msg='Query is not in a supported format') + + def _iterate_conditions(self, data, logic_op): + if isinstance(data, dict): + for cond_op, fields in data.items(): + if (cond_op in self.accepted_cond_ops): + self._iterate_fields(fields, logic_op, cond_op) + else: + self.module.fail_json(msg='Supported conditions: {0}'.format(str(self.condition_operator.keys()))) + else: + self.module.fail_json(msg='Supported conditions: {0}'.format(str(self.condition_operator.keys()))) + + def _iterate_operators(self, data): + if isinstance(data, dict): + for logic_op, cond_op in data.items(): + if (logic_op in self.logic_operators): + self.simple_query = False + self._iterate_conditions(cond_op, logic_op) + elif self.simple_query: + self.condition_operator['equals']('equals', logic_op, cond_op) + break + else: + self.module.fail_json(msg='Query is not in a supported format') + else: + self.module.fail_json(msg='Supported operators: {0}'.format(str(self.logic_operators))) + + def build_query(self): + self.qb = pysnow.QueryBuilder() + self._iterate_operators(self.data) + return (self.qb) + + +def run_module(): + # define the available arguments/parameters that a user can pass to + # the module + module_args = ServiceNowClient.snow_argument_spec() + module_args.update( + table=dict(type='str', required=False, default='incident'), + query=dict(type='dict', required=True), + max_records=dict(default=20, type='int', required=False), + order_by=dict(default='-created_on', type='str', required=False), + return_fields=dict(default=None, type='list', required=False) + ) + module_required_together = [ + ['client_id', 'client_secret'] + ] + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + required_together=module_required_together + ) + + # Connect to ServiceNow + service_now_client = ServiceNowClient(module) + conn = service_now_client.conn + + params = module.params + instance = params['instance'] + table = params['table'] + query = params['query'] + max_records = params['max_records'] + return_fields = params['return_fields'] + + result = dict( + changed=False, + instance=instance, + table=table, + query=query, + max_records=max_records, + return_fields=return_fields + ) + + # Do the lookup + try: + bq = BuildQuery(module) + qb = bq.build_query() + record = conn.query(table=module.params['table'], + query=qb) + if module.params['return_fields'] is not None: + res = record.get_multiple(fields=module.params['return_fields'], + limit=module.params['max_records'], + order_by=[module.params['order_by']]) + else: + res = record.get_multiple(limit=module.params['max_records'], + order_by=[module.params['order_by']]) + except Exception as detail: + module.fail_json(msg='Failed to find record: {0}'.format(to_native(detail)), **result) + + try: + result['record'] = list(res) + except pysnow.exceptions.NoResults: + result['record'] = [] + + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/lib/ansible/plugins/doc_fragments/service_now.py b/lib/ansible/plugins/doc_fragments/service_now.py new file mode 100644 index 00000000000..2eeb42c4bb9 --- /dev/null +++ b/lib/ansible/plugins/doc_fragments/service_now.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Ansible Project +# 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 + + +class ModuleDocFragment(object): + # Parameters for Service Now modules + DOCUMENTATION = r''' +options: + instance: + description: + - The ServiceNow instance name, without the domain, service-now.com. + required: true + type: str + username: + description: + - Name of user for connection to ServiceNow. + - Required whether using Basic or OAuth authentication. + required: true + type: str + password: + description: + - Password for username. + - Required whether using Basic or OAuth authentication. + required: true + type: str + client_id: + description: + - Client ID generated by ServiceNow. + required: false + type: str + client_secret: + description: + - Client Secret associated with client id. + required: false + type: str +''' diff --git a/test/sanity/validate-modules/ignore.txt b/test/sanity/validate-modules/ignore.txt index 5c14f2adcb1..8cfffadba4d 100644 --- a/test/sanity/validate-modules/ignore.txt +++ b/test/sanity/validate-modules/ignore.txt @@ -3004,8 +3004,6 @@ lib/ansible/modules/notification/sendgrid.py E337 lib/ansible/modules/notification/sendgrid.py E338 lib/ansible/modules/notification/slack.py E324 lib/ansible/modules/notification/slack.py E337 -lib/ansible/modules/notification/snow_record.py E317 -lib/ansible/modules/notification/snow_record.py E337 lib/ansible/modules/notification/syslogger.py E337 lib/ansible/modules/notification/telegram.py E337 lib/ansible/modules/notification/twilio.py E337