Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Attachments #91

Merged
merged 111 commits into from
Sep 6, 2021
Merged
Show file tree
Hide file tree
Changes from 97 commits
Commits
Show all changes
111 commits
Select commit Hold shift + click to select a range
84a506d
Add attachment client
MysteriousWolf Jul 27, 2021
3cac470
Add documentation fragment for attachments
MysteriousWolf Jul 27, 2021
836554e
Add bulk upload option
MysteriousWolf Jul 27, 2021
846c240
Improve documentation
MysteriousWolf Jul 27, 2021
e285a73
Add argument preset for attachments
MysteriousWolf Jul 27, 2021
7d2080f
Add a fallback for no attachments
MysteriousWolf Jul 27, 2021
a1da55e
Change type from dict to list in documentation
MysteriousWolf Jul 27, 2021
90d7d24
Fix arguments.py
MysteriousWolf Jul 28, 2021
e9df940
Fix file reading and improve payload verification
MysteriousWolf Jul 28, 2021
3e99a66
Improve attachment.py
MysteriousWolf Jul 28, 2021
979de7c
Fix batch delete
MysteriousWolf Jul 28, 2021
9485834
Add base change_request.py attachment implementation
MysteriousWolf Jul 28, 2021
1d2a6fd
Add attachment info to change requests without changes
MysteriousWolf Jul 29, 2021
e0b88ba
Fix existing change_request tests
MysteriousWolf Jul 29, 2021
21dcf6c
Add a basic attachment integration test
MysteriousWolf Jul 29, 2021
31b6fa3
Create initial unit test
MysteriousWolf Jul 29, 2021
23befb7
Attachment management
Aug 9, 2021
fdd0f91
Add attachment support for info modules with sample implementation
MysteriousWolf Aug 9, 2021
5e5c03f
Add client unit tests for request_binary
MysteriousWolf Aug 9, 2021
21f8162
Format test client
MysteriousWolf Aug 9, 2021
743a2aa
Fix sanity check errors
MysteriousWolf Aug 10, 2021
0963c80
Fix errors exposed by tests
MysteriousWolf Aug 10, 2021
96d8e5c
Add attachment unit tests
MysteriousWolf Aug 10, 2021
109433a
Improve change_request check mode attachment handling
MysteriousWolf Aug 11, 2021
c436e67
Remove full file listing from the info module
MysteriousWolf Aug 11, 2021
f4ba544
Add integration tests to supported modules
MysteriousWolf Aug 11, 2021
bffd5fd
Improve attachment implementation in the change_request.py module
MysteriousWolf Aug 11, 2021
cc1e92d
Merge remote-tracking branch 'steampunk/attachments' into feat/attach…
MysteriousWolf Aug 11, 2021
eb73eea
Update module contents to match the change_request module
MysteriousWolf Aug 11, 2021
603bd90
Fix change request tests
MysteriousWolf Aug 11, 2021
fe3ec14
Remove prints from tests
MysteriousWolf Aug 11, 2021
379fce7
Add merge_dict_lists_by_key method to utils.py
MysteriousWolf Aug 11, 2021
825febf
Refactor util tests
MysteriousWolf Aug 11, 2021
56ee1b3
Implement attachments to configuration items
MysteriousWolf Aug 11, 2021
235e41d
Improve change request update reporting
MysteriousWolf Aug 11, 2021
84845a9
Merge branch 'main' into feat/attachments
MysteriousWolf Aug 11, 2021
de03198
Fix change_request tests
MysteriousWolf Aug 11, 2021
bdb3205
Fix utils tests
MysteriousWolf Aug 11, 2021
6df99de
Remove unnecessary code
MysteriousWolf Aug 11, 2021
e96a87d
Add attachment support to the incident module
MysteriousWolf Aug 12, 2021
bc86890
Fix incident checking old data in assertions
MysteriousWolf Aug 12, 2021
076aab6
Remove unnecessary code from module tests
MysteriousWolf Aug 12, 2021
9b52741
Add attachment support to the problem module
MysteriousWolf Aug 12, 2021
ca26f04
Remove unnecessary full file downloading support
MysteriousWolf Aug 12, 2021
e1bff34
Make mime_type for create_record mandatory
MysteriousWolf Aug 12, 2021
530c20a
Update plugins/doc_fragments/attachments.py
MysteriousWolf Aug 12, 2021
146f0c2
Update plugins/doc_fragments/attachments.py
MysteriousWolf Aug 12, 2021
cf2a316
Update plugins/module_utils/arguments.py
MysteriousWolf Aug 12, 2021
f12d0d7
Update plugins/module_utils/arguments.py
MysteriousWolf Aug 12, 2021
5a9a949
Rename payload to query for better clarity
MysteriousWolf Aug 12, 2021
ec88b82
Merge remote-tracking branch 'steampunk/feat/attachments' into feat/a…
MysteriousWolf Aug 12, 2021
2844e47
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 12, 2021
ac48e07
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 12, 2021
20345bc
Merge remote-tracking branch 'steampunk/feat/attachments' into feat/a…
MysteriousWolf Aug 12, 2021
b6475a3
Implement suggested changes to attachment.py
MysteriousWolf Aug 12, 2021
6c7d2be
Rename payload to metadata
MysteriousWolf Aug 12, 2021
9efdffd
Migrate payload dictionaries to separate parameters
MysteriousWolf Aug 12, 2021
aafed05
Rename bin_data to payload
MysteriousWolf Aug 12, 2021
33afb54
Remove silent record deletion
MysteriousWolf Aug 13, 2021
d2db660
Remove error interpretation
MysteriousWolf Aug 13, 2021
b644234
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 13, 2021
e154ea8
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 13, 2021
40026d4
Merge remote-tracking branch 'steampunk/feat/attachments' into feat/a…
MysteriousWolf Aug 13, 2021
ea1985c
Merge request_binary into request
MysteriousWolf Aug 13, 2021
1b36e1b
Implement inner dictionary structure to prevent duplicates
MysteriousWolf Aug 16, 2021
a9d3712
Improve feedback from check_mode requests
MysteriousWolf Aug 16, 2021
35febae
Merge remote-tracking branch 'steampunk/main' into feat/attachments
MysteriousWolf Aug 16, 2021
438079f
Reduce request count when updating records
MysteriousWolf Aug 16, 2021
1429349
Reduce request count when checking for changes
MysteriousWolf Aug 16, 2021
ef44216
Refactor files changed in the PR
MysteriousWolf Aug 16, 2021
91593f5
Remove unnecessary return from the "delete_attached_records" method
MysteriousWolf Aug 16, 2021
89b134f
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 16, 2021
9374730
Improve query/feedback consistency
MysteriousWolf Aug 16, 2021
5a8afd2
Improve temporary metadata dict creation
MysteriousWolf Aug 16, 2021
9cb3c01
Add explanation to potentially unsafe calls with an "imaginary" sys_id
MysteriousWolf Aug 16, 2021
725638e
Revert whitespace changes
MysteriousWolf Aug 16, 2021
eeb079d
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 16, 2021
c62b4a8
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 16, 2021
3a0f94a
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 16, 2021
47e6969
Remove change reporting from non-top level dicts
MysteriousWolf Aug 16, 2021
4af0046
Merge remote-tracking branch 'steampunk/feat/attachments' into feat/a…
MysteriousWolf Aug 16, 2021
cfc2306
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 16, 2021
2354cf4
Fix issues with duplicate reporting and patch unit tests accordingly
MysteriousWolf Aug 16, 2021
79216a7
Remove visual changes
MysteriousWolf Aug 16, 2021
73bcecc
Remove file extension stripping
MysteriousWolf Aug 16, 2021
8456152
Use streams for uploading instead of binaries
MysteriousWolf Aug 16, 2021
5d246a7
Update plugins/doc_fragments/attachments.py
MysteriousWolf Aug 17, 2021
bc02461
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 17, 2021
869f843
Improve integration test validation
MysteriousWolf Aug 17, 2021
72f1f17
Update plugins/module_utils/attachment.py
MysteriousWolf Aug 17, 2021
1cac686
Merge remote-tracking branch 'steampunk/feat/attachments' into feat/a…
MysteriousWolf Aug 17, 2021
c9a7e88
Improve comments as suggested in the review
MysteriousWolf Aug 17, 2021
b15489e
Reorder dict arguments in info modules to avoid unwrapping dicts
MysteriousWolf Aug 17, 2021
4f6892f
Improve check mode order consistency
MysteriousWolf Aug 17, 2021
ab9186a
Fix attachment unit tests
MysteriousWolf Aug 17, 2021
9d80112
Improve reproducibility of attachment integration tests
MysteriousWolf Aug 17, 2021
588c598
Remove unused methods
MysteriousWolf Aug 17, 2021
3753a80
Reorder imports in alphabetical order
MysteriousWolf Aug 17, 2021
165e202
Inline sample data in unit tests
MysteriousWolf Aug 17, 2021
9d8e29a
Sort the remaining hashes
MysteriousWolf Aug 17, 2021
c490fde
Inline sample data into test functions
MysteriousWolf Aug 17, 2021
887df85
Add examples and sample return values to modules supporting attachments
MysteriousWolf Aug 17, 2021
46472d1
Merge remote-tracking branch 'steampunk/main' into feat/attachments
MysteriousWolf Aug 17, 2021
1892c96
Lowercase response header addressing in attachment.py to support chan…
MysteriousWolf Aug 17, 2021
878c82b
Send file content instead of stream for compatibility with older Pyth…
MysteriousWolf Aug 17, 2021
eae8d32
Remove unused build_query function
MysteriousWolf Aug 18, 2021
1ff5ebe
Remove unused dictionary entries from tests
MysteriousWolf Aug 18, 2021
28cf4e0
Replace temp file creation with the tmp_path fixture in tests
MysteriousWolf Aug 18, 2021
695dee1
Import unicode_literals for compatibility with older Python versions
MysteriousWolf Aug 18, 2021
233bb3c
Replace unicode_literals with the use of unicode strings where necessary
MysteriousWolf Aug 18, 2021
6c84338
Add changelog fragment
MysteriousWolf Aug 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions plugins/doc_fragments/attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2021, XLAB Steampunk <[email protected]>
#
# 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):
DOCUMENTATION = r"""
options:
attachments:
MysteriousWolf marked this conversation as resolved.
Show resolved Hide resolved
version_added: 1.2.0
description:
- ServiceNow attachments.
type: list
elements: dict
suboptions:
path:
description:
- Path to the file to be uploaded.
required: true
type: str
name:
description:
- Name of the file to be uploaded without the file extension.
- If not specified, the module will use I(path)'s base name.
type: str
type:
description:
- MIME type of the file to be attached.
- If not specified, the module will try to guess the file's type from its extension.
type: str
"""
16 changes: 16 additions & 0 deletions plugins/module_utils/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,22 @@
sys_id=dict(type="str"),
number=dict(type="str"),
query=dict(type="list", elements="dict"),
attachments=dict(
type="list",
elements="dict",
options=dict(
path=dict(
type="str",
required=True,
),
name=dict(
type="str",
),
type=dict(
type="str",
),
),
),
)


Expand Down
156 changes: 156 additions & 0 deletions plugins/module_utils/attachment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# -*- coding: utf-8 -*-
# Copyright: (c) 2021, XLAB Steampunk <[email protected]>
#
# 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

import collections
import mimetypes
import os

from . import errors


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


class AttachmentClient:
def __init__(self, client, batch_size=10000):
# 10000 records is default batch size for ServiceNow Attachment REST API, so we also use it
# as a default.
self.client = client
self.batch_size = batch_size

def list_records(self, query=None):
base_query = dict(query or {}, 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(), 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

def create_record(self, query, data, mime_type, check_mode):
if check_mode:
return query
MysteriousWolf marked this conversation as resolved.
Show resolved Hide resolved
return self.client.request(
"POST",
_path("file"),
query=query,
headers={"Accept": "application/json", "Content-type": mime_type},
bytes=data,
).json["result"]

def upload_record(self, table, table_sys_id, metadata, check_mode):
# Table and table_sys_id parameters uniquely identify the record we will attach a file to.
query = dict(
table_name=table,
table_sys_id=table_sys_id,
file_name=metadata["name"],
content_type=metadata["type"],
hash=metadata["hash"],
)
try:
with open(metadata["path"], "rb") as file_obj:
return self.create_record(
query, file_obj, query["content_type"], check_mode
)
except (IOError, OSError):
raise errors.ServiceNowError("Cannot open {0}".format(metadata["path"]))

def upload_records(self, table, table_sys_id, metadata_dict, check_mode):
MysteriousWolf marked this conversation as resolved.
Show resolved Hide resolved
return [
self.upload_record(
table, table_sys_id, dict(metadata, name=name), check_mode
)
for name, metadata in metadata_dict.items()
]

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

def delete_attached_records(self, table, table_sys_id, check_mode):
for record in self.list_records(
dict(table_name=table, table_sys_id=table_sys_id)
):
self.delete_record(record, check_mode)

def update_records(
self, table, table_sys_id, metadata_dict, records, check_mode
):
mapped_records = dict((r["file_name"], r) for r in records)

for name, metadata in metadata_dict.items():
record = mapped_records.get(name, {})
if record.get("hash") != metadata["hash"]:
if record:
self.delete_record(record, check_mode)
mapped_records[name] = self.upload_record(
table, table_sys_id, dict(metadata, name=name), check_mode
)

return list(mapped_records.values())


def transform_metadata_list(metadata_list, hashing_method):
metadata_dict = dict()
dups = collections.defaultdict(list)

for metadata in metadata_list or []:
name = get_file_name(metadata)
dups[name].append(metadata["path"])
metadata_dict[name] = {
"path": metadata["path"],
"type": get_file_type(metadata),
"hash": hashing_method(metadata["path"]),
}

dup_sets = ["({0})".format(", ".join(v)) for v in dups.values() if len(v) > 1]
if dup_sets:
raise errors.ServiceNowError(
"Found the following duplicates: {0}".format(" ".join(dup_sets))
)
return metadata_dict


def get_file_name(metadata):
if "name" in metadata and metadata["name"] is not None:
return metadata["name"]
return os.path.basename(metadata["path"])


def get_file_type(metadata):
if "type" in metadata and metadata["type"] is not None:
return metadata["type"]
return mimetypes.guess_type(metadata["path"])[0]


def build_query(table, table_sys_id, metadata):
return dict(
file_name=get_file_name(metadata),
content_type=get_file_type(metadata),
table_name=table,
table_sys_id=table_sys_id,
)


MysteriousWolf marked this conversation as resolved.
Show resolved Hide resolved
def are_changed(records, metadata_dict):
mapped_records = dict((r["file_name"], r) for r in records)
return [
metadata["hash"] != mapped_records.get(name, {}).get("hash")
for name, metadata in metadata_dict.items()
]
15 changes: 13 additions & 2 deletions plugins/module_utils/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
from .errors import ServiceNowError, AuthError, UnexpectedAPIResponse


DEFAULT_HEADERS = dict(Accept="application/json")


class Response:
def __init__(self, status, data, headers=None):
self.status = status
Expand Down Expand Up @@ -129,15 +132,23 @@ def _request(self, method, path, data=None, headers=None):
return Response(raw_resp.getcode(), raw_resp.read(), raw_resp.info())
return Response(raw_resp.status, raw_resp.read(), raw_resp.headers)

def request(self, method, path, query=None, data=None):
def request(self, method, path, query=None, data=None, headers=None, bytes=None):
# Make sure we only have one kind of payload
if data is not None and bytes is not None:
raise AssertionError(
"Cannot have JSON and binary payload in a single request."
)

escaped_path = quote(path.rstrip("/"))
url = "{0}/api/now/{1}".format(self.host, escaped_path)
if query:
url = "{0}?{1}".format(url, urlencode(query))
headers = dict(Accept="application/json", **self.auth_header)
headers = dict(headers or DEFAULT_HEADERS, **self.auth_header)
if data is not None:
data = json.dumps(data, separators=(",", ":"))
headers["Content-type"] = "application/json"
elif bytes is not None:
data = bytes
return self._request(method, url, data=data, headers=headers)

def get(self, path, query=None):
Expand Down
63 changes: 54 additions & 9 deletions plugins/modules/change_request.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
- Manca Bizjak (@mancabizjak)
- Miha Dolinar (@mdolin)
- Tadej Borovsak (@tadeboro)
- Matej Pevec (@mysteriouswolf)

short_description: Manage ServiceNow change requests

Expand All @@ -28,6 +29,7 @@
- servicenow.itsm.instance
- servicenow.itsm.sys_id
- servicenow.itsm.number
- servicenow.itsm.attachments

seealso:
- module: servicenow.itsm.change_request_info
Expand Down Expand Up @@ -193,7 +195,16 @@

from ansible.module_utils.basic import AnsibleModule

from ..module_utils import arguments, client, table, errors, utils, validation

from ..module_utils import (
arguments,
client,
table,
attachment,
errors,
utils,
validation,
)
from ..module_utils.change_request import PAYLOAD_FIELDS_MAPPING


Expand All @@ -215,12 +226,17 @@
)


def ensure_absent(module, table_client):
def ensure_absent(module, table_client, attachment_client):
mapper = utils.PayloadMapper(PAYLOAD_FIELDS_MAPPING, module.warn)
query = utils.filter_dict(module.params, "sys_id", "number")
change = table_client.get_record("change_request", query)

if change:
attachment_client.delete_attached_records(
"change_request",
change["sys_id"],
module.check_mode,
)
table_client.delete_record("change_request", change, module.check_mode)
return True, None, dict(before=mapper.to_ansible(change), after=None)

Expand All @@ -242,10 +258,13 @@ def validate_params(params, change_request=None):
)


def ensure_present(module, table_client):
def ensure_present(module, table_client, attachment_client):
MysteriousWolf marked this conversation as resolved.
Show resolved Hide resolved
mapper = utils.PayloadMapper(PAYLOAD_FIELDS_MAPPING, module.warn)
query = utils.filter_dict(module.params, "sys_id", "number")
payload = build_payload(module, table_client)
attachments = attachment.transform_metadata_list(
module.params["attachments"], module.sha256
)

if not query:
# User did not specify existing change request, so we need to create a new one.
Expand All @@ -255,12 +274,29 @@ def ensure_present(module, table_client):
"change_request", mapper.to_snow(payload), module.check_mode
)
)

# When we execute in check mode, new["sys_id"] is not defined.
# In order to give users back as much info as possible, we fake the sys_id in the
# next call.
new["attachments"] = attachment_client.upload_records(
"change_request",
new.get("sys_id", "N/A"),
MysteriousWolf marked this conversation as resolved.
Show resolved Hide resolved
attachments,
module.check_mode,
)
return True, new, dict(before=None, after=new)

old = mapper.to_ansible(
table_client.get_record("change_request", query, must_exist=True)
)
if utils.is_superset(old, payload):

old["attachments"] = attachment_client.list_records(
dict(table_name="change_request", table_sys_id=old["sys_id"])
)

if utils.is_superset(old, payload) and not any(
attachment.are_changed(old["attachments"], attachments)
):
# No change in parameters we are interested in - nothing to do.
return False, old, dict(before=old, after=old)

Expand All @@ -273,6 +309,14 @@ def ensure_present(module, table_client):
module.check_mode,
)
)
new["attachments"] = attachment_client.update_records(
"change_request",
old["sys_id"],
attachments,
old["attachments"],
module.check_mode,
)

return True, new, dict(before=old, after=new)


Expand Down Expand Up @@ -308,15 +352,15 @@ def build_payload(module, table_client):
return payload


def run(module, table_client):
def run(module, table_client, attachment_client):
if module.params["state"] == "absent":
return ensure_absent(module, table_client)
return ensure_present(module, table_client)
return ensure_absent(module, table_client, attachment_client)
return ensure_present(module, table_client, attachment_client)


def main():
module_args = dict(
arguments.get_spec("instance", "sys_id", "number"),
arguments.get_spec("instance", "sys_id", "number", "attachments"),
state=dict(
type="str",
choices=[
Expand Down Expand Up @@ -434,7 +478,8 @@ def main():
try:
snow_client = client.Client(**module.params["instance"])
table_client = table.TableClient(snow_client)
changed, record, diff = run(module, table_client)
attachment_client = attachment.AttachmentClient(snow_client)
changed, record, diff = run(module, table_client, attachment_client)
module.exit_json(changed=changed, record=record, diff=diff)
except errors.ServiceNowError as e:
module.fail_json(msg=str(e))
Expand Down
Loading