From 655d35cd06f4b974c2f180566af57b23363240bf Mon Sep 17 00:00:00 2001 From: Evgeniy Krysanov Date: Fri, 29 Mar 2019 15:42:15 +0300 Subject: [PATCH] Add Bitbucket Pipelines known host module (#54238) * Add module to manage Bitbucket Pipelines "Known Hosts" * Rename bitbucket_pipelines_known_host to bitbucket_pipeline_known_host * Rename `hostname` module param to `name` * Rename `public_key` module param to `key` * Fix documentation `version_added` tag * Remove suppression of deprecation warnings * Added check availability of package `paramiko` * Make `paramiko` package optional if `key` is present * Apply suggestions from code review Co-Authored-By: catcombo * Add error messages for invalid username or repository * Add detection of public key type for key passed as parameter * Restore Python 2 compatibility * Fix error message wording --- .../bitbucket_pipeline_known_host.py | 310 ++++++++++++++++++ .../test_bitbucket_pipeline_known_host.py | 178 ++++++++++ 2 files changed, 488 insertions(+) create mode 100644 lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_known_host.py create mode 100644 test/units/modules/source_control/test_bitbucket_pipeline_known_host.py diff --git a/lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_known_host.py b/lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_known_host.py new file mode 100644 index 00000000000..b7864610d8e --- /dev/null +++ b/lib/ansible/modules/source_control/bitbucket/bitbucket_pipeline_known_host.py @@ -0,0 +1,310 @@ +#!/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_known_host +short_description: Manages Bitbucket pipeline known hosts +description: + - Manages Bitbucket pipeline known hosts under the "SSH Keys" menu. + - The host fingerprint will be retrieved automatically, but in case of an error, one can use I(key) field to specify it manually. +version_added: "2.8" +author: + - Evgeniy Krysanov (@catcombo) +requirements: + - paramiko +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 FQDN of the known host. + type: str + required: true + key: + description: + - The public key. + type: str + state: + description: + - Indicates desired state of the record. + 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. +''' + +EXAMPLES = r''' +- name: Create known hosts from the list + bitbucket_pipeline_known_host: + repository: 'bitbucket-repo' + username: bitbucket_username + name: '{{ item }}' + state: present + with_items: + - bitbucket.org + - example.com + +- name: Remove known host + bitbucket_pipeline_known_host: + repository: bitbucket-repo + username: bitbucket_username + name: bitbucket.org + state: absent + +- name: Specify public key file + bitbucket_pipeline_known_host: + repository: bitbucket-repo + username: bitbucket_username + name: bitbucket.org + key: '{{lookup("file", "bitbucket.pub") }}' + state: absent +''' + +RETURN = r''' # ''' + +import socket + +try: + import paramiko + HAS_PARAMIKO = True +except ImportError: + HAS_PARAMIKO = False + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.source_control.bitbucket import BitbucketHelper + +error_messages = { + 'invalid_params': 'Account or repository was not found', + 'unknown_key_type': 'Public key type is unknown', +} + +BITBUCKET_API_ENDPOINTS = { + 'known-host-list': '%s/2.0/repositories/{username}/{repo_slug}/pipelines_config/ssh/known_hosts/' % BitbucketHelper.BITBUCKET_API_URL, + 'known-host-detail': '%s/2.0/repositories/{username}/{repo_slug}/pipelines_config/ssh/known_hosts/{known_host_uuid}' % BitbucketHelper.BITBUCKET_API_URL, +} + + +def get_existing_known_host(module, bitbucket): + """ + Search for a host in Bitbucket pipelines known hosts + with the name specified in module param `name` + + :param module: instance of the :class:`AnsibleModule` + :param bitbucket: instance of the :class:`BitbucketHelper` + :return: existing host or None if not found + :rtype: dict or None + + Return example:: + + { + 'type': 'pipeline_known_host', + 'uuid': '{21cc0590-bebe-4fae-8baf-03722704119a7}' + 'hostname': 'bitbucket.org', + 'public_key': { + 'type': 'pipeline_ssh_public_key', + 'md5_fingerprint': 'md5:97:8c:1b:f2:6f:14:6b:4b:3b:ec:aa:46:46:74:7c:40', + 'sha256_fingerprint': 'SHA256:zzXQOXSFBEiUtuE8AikoYKwbHaxvSc0ojez9YXaGp1A', + 'key_type': 'ssh-rsa', + 'key': 'AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kN...seeFVBoGqzHM9yXw==' + }, + } + """ + content = { + 'next': BITBUCKET_API_ENDPOINTS['known-host-list'].format( + username=module.params['username'], + repo_slug=module.params['repository'], + ) + } + + # Look through all response pages in search of hostname 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 list of known hosts: {0}'.format(info)) + + host = next(filter(lambda v: v['hostname'] == module.params['name'], content['values']), None) + + if host is not None: + return host + + return None + + +def get_host_key(module, hostname): + """ + Fetches public key for specified host + + :param module: instance of the :class:`AnsibleModule` + :param hostname: host name + :return: key type and key content + :rtype: tuple + + Return example:: + + ( + 'ssh-rsa', + 'AAAAB3NzaC1yc2EAAAABIwAAA...SBne8+seeFVBoGqzHM9yXw==', + ) + """ + try: + sock = socket.socket() + sock.connect((hostname, 22)) + except socket.error: + module.fail_json(msg='Error opening socket to {0}'.format(hostname)) + + try: + trans = paramiko.transport.Transport(sock) + trans.start_client() + host_key = trans.get_remote_server_key() + except paramiko.SSHException: + module.fail_json(msg='SSH error on retrieving {0} server key'.format(hostname)) + + trans.close() + sock.close() + + key_type = host_key.get_name() + key = host_key.get_base64() + + return key_type, key + + +def create_known_host(module, bitbucket): + hostname = module.params['name'] + key_param = module.params['key'] + + if key_param is None: + key_type, key = get_host_key(module, hostname) + elif ' ' in key_param: + key_type, key = key_param.split(' ', 1) + else: + module.fail_json(msg=error_messages['unknown_key_type']) + + info, content = bitbucket.request( + api_url=BITBUCKET_API_ENDPOINTS['known-host-list'].format( + username=module.params['username'], + repo_slug=module.params['repository'], + ), + method='POST', + data={ + 'hostname': hostname, + 'public_key': { + 'key_type': key_type, + 'key': key, + } + }, + ) + + if info['status'] == 404: + module.fail_json(msg=error_messages['invalid_params']) + + if info['status'] != 201: + module.fail_json(msg='Failed to create known host `{hostname}`: {info}'.format( + hostname=module.params['hostname'], + info=info, + )) + + +def delete_known_host(module, bitbucket, known_host_uuid): + info, content = bitbucket.request( + api_url=BITBUCKET_API_ENDPOINTS['known-host-detail'].format( + username=module.params['username'], + repo_slug=module.params['repository'], + known_host_uuid=known_host_uuid, + ), + method='DELETE', + ) + + if info['status'] == 404: + module.fail_json(msg=error_messages['invalid_params']) + + if info['status'] != 204: + module.fail_json(msg='Failed to delete known host `{hostname}`: {info}'.format( + hostname=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), + key=dict(type='str'), + state=dict(type='str', choices=['present', 'absent'], required=True), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + if (module.params['key'] is None) and (not HAS_PARAMIKO): + module.fail_json(msg='`paramiko` package not found, please install it.') + + bitbucket = BitbucketHelper(module) + + # Retrieve access token for authorized API requests + bitbucket.fetch_access_token() + + # Retrieve existing known host + existing_host = get_existing_known_host(module, bitbucket) + state = module.params['state'] + changed = False + + # Create new host in case it doesn't exists + if not existing_host and (state == 'present'): + if not module.check_mode: + create_known_host(module, bitbucket) + changed = True + + # Delete host + elif existing_host and (state == 'absent'): + if not module.check_mode: + delete_known_host(module, bitbucket, existing_host['uuid']) + changed = True + + module.exit_json(changed=changed) + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/source_control/test_bitbucket_pipeline_known_host.py b/test/units/modules/source_control/test_bitbucket_pipeline_known_host.py new file mode 100644 index 00000000000..2bbf2320801 --- /dev/null +++ b/test/units/modules/source_control/test_bitbucket_pipeline_known_host.py @@ -0,0 +1,178 @@ +from ansible.module_utils.source_control.bitbucket import BitbucketHelper +from ansible.modules.source_control.bitbucket import bitbucket_pipeline_known_host +from units.compat import unittest +from units.compat.mock import patch +from units.modules.utils import AnsibleExitJson, ModuleTestCase, set_module_args + + +class TestBucketPipelineKnownHostModule(ModuleTestCase): + def setUp(self): + super(TestBucketPipelineKnownHostModule, self).setUp() + self.module = bitbucket_pipeline_known_host + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(bitbucket_pipeline_known_host, 'get_existing_known_host', return_value=None) + def test_create_known_host(self, *args): + with patch.object(self.module, 'create_known_host') as create_known_host_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'bitbucket.org', + 'state': 'present', + }) + self.module.main() + + self.assertEqual(create_known_host_mock.call_count, 1) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + @patch.object(BitbucketHelper, 'fetch_access_token', return_value='token') + @patch.object(BitbucketHelper, 'request', return_value=(dict(status=201), dict())) + @patch.object(bitbucket_pipeline_known_host, 'get_existing_known_host', return_value=None) + def test_create_known_host_with_key(self, *args): + with patch.object(self.module, 'get_host_key') as get_host_key_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'bitbucket.org', + 'key': 'ssh-rsa public', + 'state': 'present', + }) + self.module.main() + + self.assertEqual(get_host_key_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_known_host, 'get_existing_known_host', return_value={ + 'type': 'pipeline_known_host', + 'uuid': '{21cc0590-bebe-4fae-8baf-03722704119a7}', + 'hostname': 'bitbucket.org', + 'public_key': { + 'type': 'pipeline_ssh_public_key', + 'md5_fingerprint': 'md5:97:8c:1b:f2:6f:14:6b:4b:3b:ec:aa:46:46:74:7c:40', + 'sha256_fingerprint': 'SHA256:zzXQOXSFBEiUtuE8AikoYKwbHaxvSc0ojez9YXaGp1A', + 'key_type': 'ssh-rsa', + 'key': 'AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kN...seeFVBoGqzHM9yXw==' + } + }) + def test_dont_create_same_value(self, *args): + with patch.object(self.module, 'create_known_host') as create_known_host_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'bitbucket.org', + 'state': 'present', + }) + self.module.main() + + self.assertEqual(create_known_host_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_known_host, 'get_existing_known_host', return_value=None) + def test_create_known_host_check_mode(self, *args): + with patch.object(self.module, 'create_known_host') as create_known_host_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'bitbucket.org', + 'state': 'present', + '_ansible_check_mode': True, + }) + self.module.main() + + self.assertEqual(create_known_host_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_known_host, 'get_existing_known_host', return_value={ + 'type': 'pipeline_known_host', + 'uuid': '{21cc0590-bebe-4fae-8baf-03722704119a7}', + 'hostname': 'bitbucket.org', + 'public_key': { + 'type': 'pipeline_ssh_public_key', + 'md5_fingerprint': 'md5:97:8c:1b:f2:6f:14:6b:4b:3b:ec:aa:46:46:74:7c:40', + 'sha256_fingerprint': 'SHA256:zzXQOXSFBEiUtuE8AikoYKwbHaxvSc0ojez9YXaGp1A', + 'key_type': 'ssh-rsa', + 'key': 'AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kN...seeFVBoGqzHM9yXw==' + } + }) + def test_delete_known_host(self, *args): + with patch.object(self.module, 'delete_known_host') as delete_known_host_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'bitbucket.org', + 'state': 'absent', + }) + self.module.main() + + self.assertEqual(delete_known_host_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_known_host, 'get_existing_known_host', return_value=None) + def test_delete_absent_known_host(self, *args): + with patch.object(self.module, 'delete_known_host') as delete_known_host_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'bitbucket.org', + 'state': 'absent', + }) + self.module.main() + + self.assertEqual(delete_known_host_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_known_host, 'get_existing_known_host', return_value={ + 'type': 'pipeline_known_host', + 'uuid': '{21cc0590-bebe-4fae-8baf-03722704119a7}', + 'hostname': 'bitbucket.org', + 'public_key': { + 'type': 'pipeline_ssh_public_key', + 'md5_fingerprint': 'md5:97:8c:1b:f2:6f:14:6b:4b:3b:ec:aa:46:46:74:7c:40', + 'sha256_fingerprint': 'SHA256:zzXQOXSFBEiUtuE8AikoYKwbHaxvSc0ojez9YXaGp1A', + 'key_type': 'ssh-rsa', + 'key': 'AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kN...seeFVBoGqzHM9yXw==' + } + }) + def test_delete_known_host_check_mode(self, *args): + with patch.object(self.module, 'delete_known_host') as delete_known_host_mock: + with self.assertRaises(AnsibleExitJson) as exec_info: + set_module_args({ + 'client_id': 'ABC', + 'client_secret': 'XXX', + 'username': 'name', + 'repository': 'repo', + 'name': 'bitbucket.org', + 'state': 'absent', + '_ansible_check_mode': True, + }) + self.module.main() + + self.assertEqual(delete_known_host_mock.call_count, 0) + self.assertEqual(exec_info.exception.args[0]['changed'], True) + + +if __name__ == '__main__': + unittest.main()