diff --git a/changelogs/fragments/2192-add-jira-attach.yml b/changelogs/fragments/2192-add-jira-attach.yml new file mode 100644 index 0000000000..5877250541 --- /dev/null +++ b/changelogs/fragments/2192-add-jira-attach.yml @@ -0,0 +1,2 @@ +minor_changes: + - jira - added ``attach`` operation, which allows a user to attach a file to an issue (https://github.com/ansible-collections/community.general/pull/2192). diff --git a/plugins/modules/web_infrastructure/jira.py b/plugins/modules/web_infrastructure/jira.py index 51810f6b97..d4ddf53015 100644 --- a/plugins/modules/web_infrastructure/jira.py +++ b/plugins/modules/web_infrastructure/jira.py @@ -5,6 +5,7 @@ # Atlassian open-source approval reference OSR-76. # # (c) 2020, Per Abildgaard Toft Search and update function +# (c) 2021, Brandon McNama Issue attachment functionality # # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) @@ -29,7 +30,7 @@ options: type: str required: true aliases: [ command ] - choices: [ comment, create, edit, fetch, link, search, transition, update ] + choices: [ attach, comment, create, edit, fetch, link, search, transition, update ] description: - The operation to perform. @@ -162,6 +163,29 @@ options: default: true type: bool + attachment: + type: dict + version_added: 2.5.0 + description: + - Information about the attachment being uploaded. + suboptions: + filename: + required: true + type: path + description: + - The path to the file to upload (from the remote node) or, if I(content) is specified, + the filename to use for the attachment. + content: + type: str + description: + - The Base64 encoded contents of the file to attach. If not specified, the contents of I(filename) will be + used instead. + mimetype: + type: str + description: + - The MIME type to supply for the upload. If not specified, best-effort detection will be + done. + notes: - "Currently this only works with basic-auth." - "To use with JIRA Cloud, pass the login e-mail as the I(username) and the API token as I(password)." @@ -169,6 +193,7 @@ notes: author: - "Steve Smith (@tarka)" - "Per Abildgaard Toft (@pertoft)" +- "Brandon McNama (@DWSR)" """ EXAMPLES = r""" @@ -310,10 +335,26 @@ EXAMPLES = r""" resolution: name: Done description: I am done! This is the last description I will ever give you. + +# Attach a file to an issue +- name: Attach a file + community.general.jira: + uri: '{{ server }}' + username: '{{ user }}' + password: '{{ pass }}' + issue: HSP-1 + operation: attach + attachment: + filename: topsecretreport.xlsx """ import base64 +import binascii import json +import mimetypes +import os +import random +import string import sys import traceback @@ -325,8 +366,17 @@ from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.urls import fetch_url -def request(url, user, passwd, timeout, data=None, method=None): - if data: +def request( + url, + user, + passwd, + timeout, + data=None, + method=None, + content_type='application/json', + additional_headers=None +): + if data and content_type == 'application/json': data = json.dumps(data) # NOTE: fetch_url uses a password manager, which follows the @@ -337,9 +387,18 @@ def request(url, user, passwd, timeout, data=None, method=None): # inject the basic-auth header up-front to ensure that JIRA treats # the requests as authorized for this user. auth = to_text(base64.b64encode(to_bytes('{0}:{1}'.format(user, passwd), errors='surrogate_or_strict'))) - response, info = fetch_url(module, url, data=data, method=method, timeout=timeout, - headers={'Content-Type': 'application/json', - 'Authorization': "Basic %s" % auth}) + + headers = {} + if isinstance(additional_headers) == dict: + headers = additional_headers.copy() + headers.update({ + "Content-Type": content_type, + "Authorization": "Basic %s" % auth, + }) + + response, info = fetch_url( + module, url, data=data, method=method, timeout=timeout, headers=headers + ) if info['status'] not in (200, 201, 204): error = None @@ -365,8 +424,8 @@ def request(url, user, passwd, timeout, data=None, method=None): return {} -def post(url, user, passwd, timeout, data): - return request(url, user, passwd, timeout, data=data, method='POST') +def post(url, user, passwd, timeout, data, content_type='application/json', additional_headers=None): + return request(url, user, passwd, timeout, data=data, method='POST', content_type=content_type, additional_headers=additional_headers) def put(url, user, passwd, timeout, data): @@ -486,13 +545,89 @@ def link(restbase, user, passwd, params): return True, post(url, user, passwd, params['timeout'], data) +def attach(restbase, user, passwd, params): + filename = params['attachment'].get('filename') + content = params['attachment'].get('content') + + if not any((filename, content)): + raise ValueError('at least one of filename or content must be provided') + mime = params['attachment'].get('mimetype') + + if not os.path.isfile(filename): + raise ValueError('The provided filename does not exist: %s' % filename) + + content_type, data = _prepare_attachment(filename, content, mime) + + url = restbase + '/issue/' + params['issue'] + '/attachments' + return True, post( + url, user, passwd, params['timeout'], data, content_type=content_type, + additional_headers={"X-Atlassian-Token": "no-check"} + ) + + +# Ideally we'd just use prepare_multipart from ansible.module_utils.urls, but +# unfortunately it does not support specifying the encoding and also defaults to +# base64. Jira doesn't support base64 encoded attachments (and is therefore not +# spec compliant. Go figure). I originally wrote this function as an almost +# exact copypasta of prepare_multipart, but ran into some encoding issues when +# using the noop encoder. Hand rolling the entire message body seemed to work +# out much better. +# +# https://community.atlassian.com/t5/Jira-questions/Jira-dosen-t-decode-base64-attachment-request-REST-API/qaq-p/916427 +# +# content is expected to be a base64 encoded string since Ansible doesn't +# support passing raw bytes objects. +def _prepare_attachment(filename, content=None, mime_type=None): + def escape_quotes(s): + return s.replace('"', '\\"') + + boundary = "".join(random.choice(string.digits + string.ascii_letters) for i in range(30)) + name = to_native(os.path.basename(filename)) + + if not mime_type: + try: + mime_type = mimetypes.guess_type(filename or '', strict=False)[0] or 'application/octet-stream' + except Exception: + mime_type = 'application/octet-stream' + main_type, sep, sub_type = mime_type.partition('/') + + if not content and filename: + with open(to_bytes(filename, errors='surrogate_or_strict'), 'rb') as f: + content = f.read() + else: + try: + content = base64.decode(content) + except binascii.Error as e: + raise Exception("Unable to base64 decode file content: %s" % e) + + lines = [ + "--{0}".format(boundary), + 'Content-Disposition: form-data; name="file"; filename={0}'.format(escape_quotes(name)), + "Content-Type: {0}".format("{0}/{1}".format(main_type, sub_type)), + '', + to_text(content), + "--{0}--".format(boundary), + "" + ] + + return ( + "multipart/form-data; boundary={0}".format(boundary), + "\r\n".join(lines) + ) + + def main(): global module module = AnsibleModule( argument_spec=dict( + attachment=dict(type='dict', options=dict( + content=dict(type='str'), + filename=dict(type='path', required=True), + mimetype=dict(type='str') + )), uri=dict(type='str', required=True), - operation=dict(type='str', choices=['create', 'comment', 'edit', 'update', 'fetch', 'transition', 'link', 'search'], + operation=dict(type='str', choices=['attach', 'create', 'comment', 'edit', 'update', 'fetch', 'transition', 'link', 'search'], aliases=['command'], required=True), username=dict(type='str', required=True), password=dict(type='str', required=True, no_log=True), @@ -515,6 +650,7 @@ def main(): account_id=dict(type='str'), ), required_if=( + ('operation', 'attach', ['issue', 'attachment']), ('operation', 'create', ['project', 'issuetype', 'summary']), ('operation', 'comment', ['issue', 'comment']), ('operation', 'fetch', ['issue']),