Skip to content

Commit

Permalink
Add generic snow client
Browse files Browse the repository at this point in the history
This commits adds a new client which can access services outside
"/table" namespace.

A new client, on top of the rest client, has been added to add
functionality of snow services. This is needed because the snow services
don't necessary have the same behaviour.
For example, the `Table API` server puts the total items in the custom
header `x-total-count` where others don't.

The high level clients `TableClient` and `GenericClient` inherits this client.

A new field `api_path` is added in the modules `api` and `api_info` to choose
between `TableClient` or `GenericClient`.
`api_path` is mutally exclusive with `resource` which refers to a table
in `Table API`.
```
- name: Create test ci - check mode
  servicenow.itsm.api:
    api_path: "api/now/cmdb/instance/cmdb_ci_linux_server"
      action: post
      data:
        attributes:
          name: "linux99"
          firewall_status: "intranet"
        source: "ServiceNow"
```
  • Loading branch information
tupyy committed Feb 28, 2024
1 parent d9b0551 commit 089a379
Show file tree
Hide file tree
Showing 8 changed files with 232 additions and 90 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/tests/output/
/changelogs/.plugin-cache.yaml
__pycache__
venv/

# integration_config.yml contains connection info for the
# developer instance, including credentials
Expand Down
6 changes: 3 additions & 3 deletions plugins/module_utils/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ def transform_query_to_servicenow_query(query):
)


def table_name(module):
def resource_name(module):
"""
In api.py and api_info.py the table's name is always going to be stored in module's resource
Return either the table name or the api_path
"""
return module.params["resource"]
return module.params["resource"] or module.params["api_path"]


def get_query_by_sys_id(module):
Expand Down
43 changes: 43 additions & 0 deletions plugins/module_utils/generic.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2024, Red Hat
#
# 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

from . import snow


class GenericClient(snow.SNowClient):
def __init__(self, client, batch_size=1000):
super(GenericClient, self).__init__(client, batch_size)

def list_records(self, api_path, query=None):
return self.list(api_path, query)

def get_record(self, api_path, query, must_exist=False):
return self.get(api_path, query, must_exist)

def get_record_by_sys_id(self, api_path, sys_id):
response = self.client.get("/".join((api_path, sys_id)))
record = response.json["result"]

return record

def create_record(self, api_path, payload, check_mode, query=None):
if check_mode:
# Approximate the result using the payload.
return payload
return self.create(api_path, payload, query)

def update_record(self, api_path, record, payload, check_mode, query=None):
if check_mode:
# Approximate the result by manually patching the existing state.
return dict(record, **payload)
return self.update(api_path, record["sys_id"], payload, query)

def delete_record(self, api_path, record, check_mode):
if not check_mode:
return self.delete(api_path, record["sys_id"])
83 changes: 83 additions & 0 deletions plugins/module_utils/snow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2024, Red Hat
#
# 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


from . import errors


class SNowClient:
def __init__(self, client, batch_size=1000):
self.client = client
self.batch_size = batch_size

def list(self, api_path, query=None):
base_query = self._sanitize_query(query)
base_query["sysparm_limit"] = self.batch_size

offset = 0
total = 1 # Dummy value that ensures loop executes at least once
result = []

while offset < total:
response = self.client.get(
api_path,
query=dict(base_query, sysparm_offset=offset),
)

result.extend(response.json["result"])
# This is a header only for Table API.
# When using this client for generic api, the header is not present anymore
# and we need to find a new method to break from the loop
# It can be removed from Table API but for it's better to keep it for now.
if response.headers['x-total-count']:
total = int(response.headers["x-total-count"])
else:
if len(response.json['result']) == 0:
break

offset += self.batch_size

return result

def get(self, api_path, query, must_exist=False):
records = self.list(api_path, query)

if len(records) > 1:
raise errors.ServiceNowError(
"{0} {1} records match the {2} query.".format(
len(records), api_path, query
)
)

if must_exist and not records:
raise errors.ServiceNowError(
"No {0} records match the {1} query.".format(api_path, query)
)

return records[0] if records else None

def create(self, api_path, payload, query=None):
return self.client.post(
api_path, payload, query=self._sanitize_query(query)
).json["result"]

def update(self, api_path, sys_id, payload, query=None):
return self.client.patch(
"/".join((api_path.rstrip("/"), sys_id)),
payload,
query=self._sanitize_query(query),
).json["result"]

def delete(self, api_path, sys_id):
self.client.delete("/".join((api_path.rstrip("/"), sys_id)))

def _sanitize_query(self, query):
query = query or dict()
query.setdefault("sysparm_exclude_reference_link", "true")
return query
64 changes: 8 additions & 56 deletions plugins/module_utils/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,62 +7,22 @@

__metaclass__ = type

from . import errors
from . import snow


def _path(api_path, table, *subpaths):
return "/".join(api_path + ("table", table) + subpaths)


def _query(original=None):
original = original or dict()
original.setdefault("sysparm_exclude_reference_link", "true")
return original


class TableClient:
class TableClient(snow.SNowClient):
def __init__(self, client, batch_size=1000):
# 1000 records is default batch size for ServiceNow REST API, so we also use it
# as a default.
self.client = client
self.batch_size = batch_size
super(TableClient, self).__init__(client, batch_size)

def list_records(self, table, query=None):
base_query = _query(query)
base_query["sysparm_limit"] = self.batch_size

offset = 0
total = 1 # Dummy value that ensures loop executes at least once
result = []

while offset < total:
response = self.client.get(
_path(self.client.api_path, table),
query=dict(base_query, sysparm_offset=offset),
)

result.extend(response.json["result"])
total = int(response.headers["x-total-count"])
offset += self.batch_size

return result
return self.list(_path(self.client.api_path, table), query)

def get_record(self, table, query, must_exist=False):
records = self.list_records(table, query)

if len(records) > 1:
raise errors.ServiceNowError(
"{0} {1} records match the {2} query.".format(
len(records), table, query
)
)

if must_exist and not records:
raise errors.ServiceNowError(
"No {0} records match the {1} query.".format(table, query)
)

return records[0] if records else None
return self.get(_path(self.client.api_path, table), query, must_exist)

def get_record_by_sys_id(self, table, sys_id):
response = self.client.get(_path(self.client.api_path, table, sys_id))
Expand All @@ -74,25 +34,17 @@ def create_record(self, table, payload, check_mode, query=None):
if check_mode:
# Approximate the result using the payload.
return payload

return self.client.post(
_path(self.client.api_path, table), payload, query=_query(query)
).json["result"]
return self.create(_path(self.client.api_path, table), payload, query)

def update_record(self, table, record, payload, check_mode, query=None):
if check_mode:
# Approximate the result by manually patching the existing state.
return dict(record, **payload)

return self.client.patch(
_path(self.client.api_path, table, record["sys_id"]),
payload,
query=_query(query),
).json["result"]
return self.update(_path(self.client.api_path, table), record["sys_id"], payload, query)

def delete_record(self, table, record, check_mode):
if not check_mode:
self.client.delete(_path(self.client.api_path, table, record["sys_id"]))
return self.delete(_path(self.client.api_path, table), record["sys_id"])


def find_user(table_client, user_id):
Expand Down
58 changes: 37 additions & 21 deletions plugins/modules/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,15 @@
resource:
description:
- The name of the table in which a record is to be created, updated or deleted from.
- Required one of C(resource) or C(api_path)
- Mutually exclusive with C(api_path).
type: str
api_path:
description:
- The api_path of the service in which a record is to be created, updated or deleted from.
- Required one of C(resource) or C(api_path)
- Mutually exclusive with C(resource).
type: str
required: true
action:
description: The action to perform.
type: str
Expand Down Expand Up @@ -265,7 +272,7 @@

from ansible.module_utils.basic import AnsibleModule

from ..module_utils import arguments, client, errors, table
from ..module_utils import arguments, client, errors, table, generic
from ..module_utils.api import (
ACTION_DELETE,
ACTION_PATCH,
Expand All @@ -276,17 +283,17 @@
FIELD_TEMPLATE,
field_present,
get_query_by_sys_id,
table_name,
resource_name,
)


def update_resource(module, table_client):
def update_resource(module, client):
query = get_query_by_sys_id(module)
record_old = table_client.get_record(table_name(module), query)
record_old = client.get_record(resource_name(module), query)
if record_old is None:
return False, None, dict(before=None, after=None)
record_new = table_client.update_record(
table=table_name(module),
record_new = client.update_record(
resource_name(module),
record=record_old,
payload=module.params.get(FIELD_DATA, dict()),
check_mode=module.check_mode,
Expand All @@ -295,35 +302,35 @@ def update_resource(module, table_client):
return True, record_new, dict(before=record_old, after=record_new)


def create_resource(module, table_client):
def create_resource(module, client):
# At the moment, creating a resource is not idempotent (meaning: If a record with such data as specified in
# module.params["data"] already exists, such resource will get created once again).
new = table_client.create_record(
table=table_name(module),
new = client.create_record(
resource_name(module),
payload=module.params.get(FIELD_DATA, dict()),
check_mode=module.check_mode,
query=module.params.get(FIELD_QUERY_PARAMS, dict()),
)
return True, new, dict(before=None, after=new)


def delete_resource(module, table_client):
def delete_resource(module, client):
query = get_query_by_sys_id(module)
record = table_client.get_record(table_name(module), query)
record = client.get_record(resource_name(module), query)
if record is None:
return False, None, dict(before=None, after=None)
table_client.delete_record(table_name(module), record, module.check_mode)
client.delete_record(resource_name(module), record, module.check_mode)
return True, None, dict(before=record, after=None)


def run(module, table_client):
def run(module, client):
if module.params["action"] == ACTION_PATCH: # PATCH method
return update_resource(module, table_client)
return update_resource(module, client)
elif module.params["action"] == ACTION_POST: # POST method
if field_present(module, FIELD_SYS_ID):
module.warn("For action create (post) sys_id is ignored.")
return create_resource(module, table_client)
return delete_resource(module, table_client) # DELETE method
return create_resource(module, client)
return delete_resource(module, client) # DELETE method


def main():
Expand All @@ -332,7 +339,8 @@ def main():
"instance",
"sys_id", # necessary for deleting and patching a resource, not relevant if creating a resource
),
resource=dict(type="str", required=True),
resource=dict(type="str"),
api_path=dict(type="str"),
action=dict(
type="str",
required=True,
Expand All @@ -352,7 +360,11 @@ def main():
module = AnsibleModule(
supports_check_mode=True,
argument_spec=arg_spec,
mutually_exclusive=[(FIELD_DATA, FIELD_TEMPLATE)],
mutually_exclusive=[
(FIELD_DATA, FIELD_TEMPLATE),
("resource", "api_path")
],
required_one_of=[("resource", "api_path")],
required_if=[
("action", "patch", ("sys_id",)),
("action", "delete", ("sys_id",)),
Expand All @@ -361,8 +373,12 @@ def main():

try:
snow_client = client.Client(**module.params["instance"])
table_client = table.TableClient(snow_client)
changed, record, diff = run(module, table_client)

_client = table.TableClient(snow_client)
if module.params["api_path"]:
_client = generic.GenericClient(snow_client)

changed, record, diff = run(module, _client)
module.exit_json(changed=changed, record=record, diff=diff)
except errors.ServiceNowError as e:
module.fail_json(msg=str(e))
Expand Down
Loading

0 comments on commit 089a379

Please sign in to comment.