From 907765a3a5d630666447ee7be74250bd9d2879fd Mon Sep 17 00:00:00 2001 From: Evgeniy Krysanov Date: Fri, 22 Mar 2019 15:17:08 +0300 Subject: [PATCH] Add Bitbucket Pipelines variable module (#54049) * Add Bitbucket pipelines variable module * Add tests * Remove parameters check for `absent` state * Update version_added documentation field * Minor fixes * A few additional cosmetic changes * Move to source_control * Rename lib/ansible/modules/source_control/bitbucket_pipelines_variable.py to lib/ansible/modules/source_control/bitbucket/bitbucket_pipelines_variable.py * Reflect directory change * Move these imports as well * Rename 'key' parameter (API) to 'name' (GUI) * Add missing __init__.py files to mark modules * Rename module (pipeline should be singular) * Adjust module references and variable names after renaming --- .../module_utils/source_control/__init__.py | 0 .../module_utils/source_control/bitbucket.py | 95 ++++++ .../source_control/bitbucket/__init__.py | 0 .../bitbucket/bitbucket_pipeline_variable.py | 272 ++++++++++++++++ .../test_bitbucket_pipeline_variable.py | 290 ++++++++++++++++++ 5 files changed, 657 insertions(+) create mode 100644 lib/ansible/module_utils/source_control/__init__.py create mode 100644 lib/ansible/module_utils/source_control/bitbucket.py create mode 100644 lib/ansible/modules/source_control/bitbucket/__init__.py create mode 100644 lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_variable.py create mode 100644 test/units/modules/source_control/test_bitbucket_pipeline_variable.py diff --git a/lib/ansible/module_utils/source_control/__init__.py b/lib/ansible/module_utils/source_control/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/module_utils/source_control/bitbucket.py b/lib/ansible/module_utils/source_control/bitbucket.py new file mode 100644 index 0000000000..8359eec11f --- /dev/null +++ b/lib/ansible/module_utils/source_control/bitbucket.py @@ -0,0 +1,95 @@ +# -*- coding: utf-8 -*- + +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +# Make coding more python3-ish +from __future__ import (absolute_import, division, print_function) + +import json + +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.urls import fetch_url, basic_auth_header + +# Makes all classes defined in the file into new-style classes without explicitly inheriting from object +__metaclass__ = type + + +class BitbucketHelper: + BITBUCKET_API_URL = 'https://api.bitbucket.org' + + error_messages = { + 'required_client_id': '`client_id` must be specified as a parameter or ' + 'BITBUCKET_CLIENT_ID environment variable', + 'required_client_secret': '`client_secret` must be specified as a parameter or ' + 'BITBUCKET_CLIENT_SECRET environment variable', + } + + def __init__(self, module): + self.module = module + self.access_token = None + + @staticmethod + def bitbucket_argument_spec(): + return dict( + client_id=dict(type='str', no_log=True, fallback=(env_fallback, ['BITBUCKET_CLIENT_ID'])), + client_secret=dict(type='str', no_log=True, fallback=(env_fallback, ['BITBUCKET_CLIENT_SECRET'])), + ) + + def check_arguments(self): + if self.module.params['client_id'] is None: + self.module.fail_json(msg=self.error_messages['required_client_id']) + + if self.module.params['client_secret'] is None: + self.module.fail_json(msg=self.error_messages['required_client_secret']) + + def fetch_access_token(self): + self.check_arguments() + + headers = { + 'Authorization': basic_auth_header(self.module.params['client_id'], self.module.params['client_secret']) + } + + info, content = self.request( + api_url='https://bitbucket.org/site/oauth2/access_token', + method='POST', + data='grant_type=client_credentials', + headers=headers, + ) + + if info['status'] == 200: + self.access_token = content['access_token'] + else: + self.module.fail_json(msg='Failed to retrieve access token: {0}'.format(info)) + + def request(self, api_url, method, data=None, headers=None): + headers = headers or {} + + if self.access_token: + headers.update({ + 'Authorization': 'Bearer {0}'.format(self.access_token), + }) + + if isinstance(data, dict): + data = self.module.jsonify(data) + headers.update({ + 'Content-type': 'application/json', + }) + + response, info = fetch_url( + module=self.module, + url=api_url, + method=method, + headers=headers, + data=data, + force=True, + ) + + content = {} + + if response is not None: + body = to_text(response.read()) + if body: + content = json.loads(body) + + return info, content diff --git a/lib/ansible/modules/source_control/bitbucket/__init__.py b/lib/ansible/modules/source_control/bitbucket/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_variable.py b/lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_variable.py new file mode 100644 index 0000000000..f34cb12f3f --- /dev/null +++ b/lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_variable.py @@ -0,0 +1,272 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Evgeniy Krysanov +# 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 = r''' +--- +module: bitbucket_pipeline_variable +short_description: Manages Bitbucket pipeline variables +description: + - Manages Bitbucket pipeline variables. +version_added: "2.8" +author: + - Evgeniy Krysanov (@catcombo) +options: + client_id: + description: + - The OAuth consumer key. + - If not set the environment variable C(BITBUCKET_CLIENT_ID) will be used. + type: str + client_secret: + description: + - The OAuth consumer secret. + - If not set the environment variable C(BITBUCKET_CLIENT_SECRET) will be used. + type: str + repository: + description: + - The repository name. + type: str + required: true + username: + description: + - The repository owner. + type: str + required: true + name: + description: + - The pipeline variable name. + type: str + required: true + value: + description: + - The pipeline variable value. + type: str + secured: + description: + - Whether to encrypt the variable value. + type: bool + default: no + state: + description: + - Indicates desired state of the variable. + type: str + required: true + choices: [ absent, present ] +notes: + - Bitbucket OAuth consumer key and secret can be obtained from Bitbucket profile -> Settings -> Access Management -> OAuth. + - Check mode is supported. + - For secured values return parameter C(changed) is always C(True). +''' + +EXAMPLES = r''' +- name: Create or update pipeline variables from the list + bitbucket_pipeline_variable: + repository: 'bitbucket-repo' + username: bitbucket_username + name: '{{ item.name }}' + value: '{{ item.value }}' + secured: '{{ item.secured }}' + state: present + with_items: + - { name: AWS_ACCESS_KEY, value: ABCD1234 } + - { name: AWS_SECRET, value: qwe789poi123vbn0, secured: True } + +- name: Remove pipeline variable + bitbucket_pipeline_variable: + repository: bitbucket-repo + username: bitbucket_username + name: AWS_ACCESS_KEY + state: absent +''' + +RETURN = r''' # ''' + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.source_control.bitbucket import BitbucketHelper + +error_messages = { + 'required_value': '`value` is required when the `state` is `present`', +} + +BITBUCKET_API_ENDPOINTS = { + 'pipeline-variable-list': '%s/2.0/repositories/{username}/{repo_slug}/pipelines_config/variables/' % BitbucketHelper.BITBUCKET_API_URL, + 'pipeline-variable-detail': '%s/2.0/repositories/{username}/{repo_slug}/pipelines_config/variables/{variable_uuid}' % BitbucketHelper.BITBUCKET_API_URL, +} + + +def get_existing_pipeline_variable(module, bitbucket): + """ + Search for a pipeline variable + + :param module: instance of the :class:`AnsibleModule` + :param bitbucket: instance of the :class:`BitbucketHelper` + :return: existing variable or None if not found + :rtype: dict or None + + Return example:: + + { + 'name': 'AWS_ACCESS_OBKEY_ID', + 'value': 'x7HU80-a2', + 'type': 'pipeline_variable', + 'secured': False, + 'uuid': '{9ddb0507-439a-495a-99f3-5464f15128127}' + } + + The `value` key in dict is absent in case of secured variable. + """ + content = { + 'next': BITBUCKET_API_ENDPOINTS['pipeline-variable-list'].format( + username=module.params['username'], + repo_slug=module.params['repository'], + ) + } + + # Look through the all response pages in search of variable we need + while 'next' in content: + info, content = bitbucket.request( + api_url=content['next'], + method='GET', + ) + + if info['status'] == 404: + module.fail_json(msg='Invalid `repository` or `username`.') + + if info['status'] != 200: + module.fail_json(msg='Failed to retrieve the list of pipeline variables: {0}'.format(info)) + + var = next(filter(lambda v: v['key'] == module.params['name'], content['values']), None) + + if var is not None: + var['name'] = var.pop('key') + return var + + return None + + +def create_pipeline_variable(module, bitbucket): + info, content = bitbucket.request( + api_url=BITBUCKET_API_ENDPOINTS['pipeline-variable-list'].format( + username=module.params['username'], + repo_slug=module.params['repository'], + ), + method='POST', + data={ + 'key': module.params['name'], + 'value': module.params['value'], + 'secured': module.params['secured'], + }, + ) + + if info['status'] != 201: + module.fail_json(msg='Failed to create pipeline variable `{name}`: {info}'.format( + name=module.params['name'], + info=info, + )) + + +def update_pipeline_variable(module, bitbucket, variable_uuid): + info, content = bitbucket.request( + api_url=BITBUCKET_API_ENDPOINTS['pipeline-variable-detail'].format( + username=module.params['username'], + repo_slug=module.params['repository'], + variable_uuid=variable_uuid, + ), + method='PUT', + data={ + 'value': module.params['value'], + 'secured': module.params['secured'], + }, + ) + + if info['status'] != 200: + module.fail_json(msg='Failed to update pipeline variable `{name}`: {info}'.format( + name=module.params['name'], + info=info, + )) + + +def delete_pipeline_variable(module, bitbucket, variable_uuid): + info, content = bitbucket.request( + api_url=BITBUCKET_API_ENDPOINTS['pipeline-variable-detail'].format( + username=module.params['username'], + repo_slug=module.params['repository'], + variable_uuid=variable_uuid, + ), + method='DELETE', + ) + + if info['status'] != 204: + module.fail_json(msg='Failed to delete pipeline variable `{name}`: {info}'.format( + name=module.params['name'], + info=info, + )) + + +def main(): + argument_spec = BitbucketHelper.bitbucket_argument_spec() + argument_spec.update( + repository=dict(type='str', required=True), + username=dict(type='str', required=True), + name=dict(type='str', required=True), + value=dict(type='str'), + secured=dict(type='bool', default=False), + state=dict(type='str', choices=['present', 'absent'], required=True), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + bitbucket = BitbucketHelper(module) + + value = module.params['value'] + state = module.params['state'] + secured = module.params['secured'] + + # Check parameters + if (value is None) and (state == 'present'): + module.fail_json(msg=error_messages['required_value']) + + # Retrieve access token for authorized API requests + bitbucket.fetch_access_token() + + # Retrieve existing pipeline variable (if any) + existing_variable = get_existing_pipeline_variable(module, bitbucket) + changed = False + + # Create new variable in case it doesn't exists + if not existing_variable and (state == 'present'): + if not module.check_mode: + create_pipeline_variable(module, bitbucket) + changed = True + + # Update variable if it is secured or the old value does not match the new one + elif existing_variable and (state == 'present'): + if (existing_variable['secured'] != secured) or (existing_variable.get('value') != value): + if not module.check_mode: + update_pipeline_variable(module, bitbucket, existing_variable['uuid']) + changed = True + + # Delete variable + elif existing_variable and (state == 'absent'): + if not module.check_mode: + delete_pipeline_variable(module, bitbucket, existing_variable['uuid']) + changed = True + + module.exit_json(changed=changed) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/source_control/test_bitbucket_pipeline_variable.py b/test/units/modules/source_control/test_bitbucket_pipeline_variable.py new file mode 100644 index 0000000000..0cb87d0c5e --- /dev/null +++ b/test/units/modules/source_control/test_bitbucket_pipeline_variable.py @@ -0,0 +1,290 @@ +from ansible.module_utils.source_control.bitbucket import BitbucketHelper +from ansible.modules.source_control.bitbucket import bitbucket_pipeline_variable +from units.compat import unittest +from units.compat.mock import patch +from units.modules.utils import AnsibleFailJson, AnsibleExitJson, ModuleTestCase, set_module_args + + +class TestBucketPipelineVariableModule(ModuleTestCase): + def setUp(self): + super(TestBucketPipelineVariableModule, self).setUp() + self.module = bitbucket_pipeline_variable + + def test_without_required_parameters(self): + with self.assertRaises(AnsibleFailJson) as exec_info: + set_module_args({ + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'state': 'absent', + }) + self.module.main() + + self.assertEqual(exec_info.exception.args[0]['msg'], BitbucketHelper.error_messages['required_client_id']) + + def test_missing_value_with_present_state(self): + with self.assertRaises(AnsibleFailJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'state': 'present', + }) + self.module.main() + + self.assertEqual(exec_info.exception.args[0]['msg'], self.module.error_messages['required_value']) + + @patch.dict('os.environ', { + 'BITBUCKET_CLIENT_ID': 'ABC', + 'BITBUCKET_CLIENT_SECRET': 'XXX', + }) + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value=None) + def test_env_vars_params(self, *args): + with self.assertRaises(AnsibleExitJson): + set_module_args({ + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'state': 'absent', + }) + self.module.main() + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value=None) + def test_create_variable(self, *args): + with patch.object(self.module, 'create_pipeline_variable') as create_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'state': 'present', + }) + self.module.main() + + self.assertEqual(create_pipeline_variable_mock.call_count, 1) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value=None) + def test_create_variable_check_mode(self, *args): + with patch.object(self.module, 'create_pipeline_variable') as create_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'state': 'present', + '_ansible_check_mode': True, + }) + self.module.main() + + self.assertEqual(create_pipeline_variable_mock.call_count, 0) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value={ + 'name': 'PIPELINE_VAR_NAME', + 'value': 'Im alive', + 'type': 'pipeline_variable', + 'secured': False, + 'uuid': '{9ddb0507-439a-495a- 99f3 - 564f15138127}' + }) + def test_update_variable(self, *args): + with patch.object(self.module, 'update_pipeline_variable') as update_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'state': 'present', + }) + self.module.main() + + self.assertEqual(update_pipeline_variable_mock.call_count, 1) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value={ + 'name': 'PIPELINE_VAR_NAME', + 'type': 'pipeline_variable', + 'secured': True, + 'uuid': '{9ddb0507-439a-495a- 99f3 - 564f15138127}' + }) + def test_update_secured_variable(self, *args): + with patch.object(self.module, 'update_pipeline_variable') as update_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'secured': True, + 'state': 'present', + }) + self.module.main() + + self.assertEqual(update_pipeline_variable_mock.call_count, 1) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value={ + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'type': 'pipeline_variable', + 'secured': False, + 'uuid': '{9ddb0507-439a-495a- 99f3 - 564f15138127}' + }) + def test_update_secured_state(self, *args): + with patch.object(self.module, 'update_pipeline_variable') as update_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'secured': True, + 'state': 'present', + }) + self.module.main() + + self.assertEqual(update_pipeline_variable_mock.call_count, 1) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value={ + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'type': 'pipeline_variable', + 'secured': False, + 'uuid': '{9ddb0507-439a-495a- 99f3 - 564f15138127}' + }) + def test_dont_update_same_value(self, *args): + with patch.object(self.module, 'update_pipeline_variable') as update_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'state': 'present', + }) + self.module.main() + + self.assertEqual(update_pipeline_variable_mock.call_count, 0) + self.assertEqual(exec_info.exception.args[0]['changed'], False) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value={ + 'name': 'PIPELINE_VAR_NAME', + 'value': 'Im alive', + 'type': 'pipeline_variable', + 'secured': False, + 'uuid': '{9ddb0507-439a-495a- 99f3 - 564f15138127}' + }) + def test_update_variable_check_mode(self, *args): + with patch.object(self.module, 'update_pipeline_variable') as update_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'value': '42', + 'state': 'present', + '_ansible_check_mode': True, + }) + self.module.main() + + self.assertEqual(update_pipeline_variable_mock.call_count, 0) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value={ + 'name': 'PIPELINE_VAR_NAME', + 'value': 'Im alive', + 'type': 'pipeline_variable', + 'secured': False, + 'uuid': '{9ddb0507-439a-495a- 99f3 - 564f15138127}' + }) + def test_delete_variable(self, *args): + with patch.object(self.module, 'delete_pipeline_variable') as delete_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'state': 'absent', + }) + self.module.main() + + self.assertEqual(delete_pipeline_variable_mock.call_count, 1) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value=None) + def test_delete_absent_variable(self, *args): + with patch.object(self.module, 'delete_pipeline_variable') as delete_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'state': 'absent', + }) + self.module.main() + + self.assertEqual(delete_pipeline_variable_mock.call_count, 0) + self.assertEqual(exec_info.exception.args[0]['changed'], False) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_variable, 'get_existing_pipeline_variable', return_value={ + 'name': 'PIPELINE_VAR_NAME', + 'value': 'Im alive', + 'type': 'pipeline_variable', + 'secured': False, + 'uuid': '{9ddb0507-439a-495a- 99f3 - 564f15138127}' + }) + def test_delete_variable_check_mode(self, *args): + with patch.object(self.module, 'delete_pipeline_variable') as delete_pipeline_variable_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'PIPELINE_VAR_NAME', + 'state': 'absent', + '_ansible_check_mode': True, + }) + self.module.main() + + self.assertEqual(delete_pipeline_variable_mock.call_count, 0) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + +if __name__ == '__main__': + unittest.main()