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 <akasurde@redhat.com>
This commit is contained in:
n3pjk 2019-07-13 11:04:36 -04:00 committed by Abhijeet Kasurde
parent d910c971b4
commit a135c483ce
5 changed files with 489 additions and 78 deletions

View file

@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2019, Ansible Project
# Copyright: (c) 2017, Tim Rightnour <thegarbledone@gmail.com>
# 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),
)

View file

@ -1,5 +1,5 @@
#!/usr/bin/python
# Copyright (c) 2017 Tim Rightnour <thegarbledone@gmail.com>
# Copyright: (c) 2017, Tim Rightnour <thegarbledone@gmail.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
@ -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)

View file

@ -0,0 +1,277 @@
#!/usr/bin/python
# Copyright: (c) 2017, Tim Rightnour <thegarbledone@gmail.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: 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()

View file

@ -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
'''

View file

@ -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