From 279cf596dca402a93c23baca4b608c2536d5f2b7 Mon Sep 17 00:00:00 2001 From: Marcus Watkins Date: Thu, 17 May 2018 11:52:48 -0600 Subject: [PATCH] New Module: gitlab_hooks module and related tests (#40096) * Added gitlab_hooks module and related tests * Fix sanity check issues * Refactor to use common util method, add check_mode support * Fix module shebang --- .../modules/source_control/gitlab_hooks.py | 300 ++++++++++++++++++ .../source_control/test_gitlab_hooks.py | 288 +++++++++++++++++ 2 files changed, 588 insertions(+) create mode 100755 lib/ansible/modules/source_control/gitlab_hooks.py create mode 100755 test/units/modules/source_control/test_gitlab_hooks.py diff --git a/lib/ansible/modules/source_control/gitlab_hooks.py b/lib/ansible/modules/source_control/gitlab_hooks.py new file mode 100755 index 00000000000..7e17b8ea852 --- /dev/null +++ b/lib/ansible/modules/source_control/gitlab_hooks.py @@ -0,0 +1,300 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# (c) 2018, Marcus Watkins +# Based on code: +# (c) 2013, Phillip Gentry +# 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 = ''' +--- +module: gitlab_hooks +short_description: Manages GitLab project hooks. +description: + - Adds, updates and removes project hooks +version_added: "2.6" +options: + api_url: + description: + - GitLab API url, e.g. https://gitlab.example.com/api + required: true + access_token: + description: + - The oauth key provided by GitLab. One of access_token or private_token is required. See https://docs.gitlab.com/ee/api/oauth2.html + required: false + private_token: + description: + - Personal access token to use. One of private_token or access_token is required. See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html + required: false + project: + description: + - Numeric project id or name of project in the form of group/name + required: true + hook_url: + description: + - The url that you want GitLab to post to, this is used as the primary key for updates and deletion. + required: true + state: + description: + - When C(present) the hook will be updated to match the input or created if it doesn't exist. When C(absent) it will be deleted if it exists. + required: true + default: present + choices: [ "present", "absent" ] + push_events: + description: + - Trigger hook on push events + type: bool + default: 'yes' + issues_events: + description: + - Trigger hook on issues events + type: bool + default: 'no' + merge_requests_events: + description: + - Trigger hook on merge requests events + type: bool + default: 'no' + tag_push_events: + description: + - Trigger hook on tag push events + type: bool + default: 'no' + note_events: + description: + - Trigger hook on note events + type: bool + default: 'no' + job_events: + description: + - Trigger hook on job events + type: bool + default: 'no' + pipeline_events: + description: + - Trigger hook on pipeline events + type: bool + default: 'no' + wiki_page_events: + description: + - Trigger hook on wiki events + type: bool + default: 'no' + enable_ssl_verification: + description: + - Whether GitLab will do SSL verification when triggering the hook + type: bool + default: 'no' + token: + description: + - Secret token to validate hook messages at the receiver. + - If this is present it will always result in a change as it cannot be retrieved from GitLab. + - Will show up in the X-Gitlab-Token HTTP request header + required: false +author: "Marcus Watkins (@marwatk)" +''' + +EXAMPLES = ''' +# Example creating a new project hook +- gitlab_hooks: + api_url: https://gitlab.example.com/api + access_token: "{{ access_token }}" + project: "my_group/my_project" + hook_url: "https://my-ci-server.example.com/gitlab-hook" + state: present + push_events: yes + enable_ssl_verification: no + token: "my-super-secret-token-that-my-ci-server-will-check" + +# Update the above hook to add tag pushes +- gitlab_hooks: + api_url: https://gitlab.example.com/api + access_token: "{{ access_token }}" + project: "my_group/my_project" + hook_url: "https://my-ci-server.example.com/gitlab-hook" + state: present + push_events: yes + tag_push_events: yes + enable_ssl_verification: no + token: "my-super-secret-token-that-my-ci-server-will-check" + +# Delete the previous hook +- gitlab_hooks: + api_url: https://gitlab.example.com/api + access_token: "{{ access_token }}" + project: "my_group/my_project" + hook_url: "https://my-ci-server.example.com/gitlab-hook" + state: absent + +# Delete a hook by numeric project id +- gitlab_hooks: + api_url: https://gitlab.example.com/api + access_token: "{{ access_token }}" + project: 10 + hook_url: "https://my-ci-server.example.com/gitlab-hook" + state: absent +''' + +RETURN = ''' +msg: + description: Success or failure message + returned: always + type: string + sample: "Success" + +result: + description: json parsed response from the server + returned: always + type: dict + +error: + description: the error message returned by the Gitlab API + returned: failed + type: string + sample: "400: key is already in use" + +previous_version: + description: object describing the state prior to this task + returned: changed + type: dict +''' + + +import json + +from ansible.module_utils.basic import AnsibleModule +from copy import deepcopy + +from ansible.module_utils.gitlab import request + + +def _list(module, api_url, project, access_token, private_token): + path = "/hooks" + return request(module, api_url, project, path, access_token, private_token) + + +def _find(module, api_url, project, hook_url, access_token, private_token): + success, data = _list(module, api_url, project, access_token, private_token) + if success: + for i in data: + if i["url"] == hook_url: + return success, i + return success, None + return success, data + + +def _publish(module, api_url, project, data, access_token, private_token): + path = "/hooks" + method = "POST" + if 'id' in data: + path += "/%s" % str(data["id"]) + method = "PUT" + data = deepcopy(data) + data.pop('id', None) + return request(module, api_url, project, path, access_token, private_token, json.dumps(data, sort_keys=True), method) + + +def _delete(module, api_url, project, hook_id, access_token, private_token): + path = "/hooks/%s" % str(hook_id) + return request(module, api_url, project, path, access_token, private_token, method='DELETE') + + +def _are_equivalent(input, existing): + for key in [ + 'url', 'push_events', 'issues_events', 'merge_requests_events', + 'tag_push_events', 'note_events', 'job_events', 'pipeline_events', 'wiki_page_events', + 'enable_ssl_verification']: + if key in input and key not in existing: + return False + if key not in input and key in existing: + return False + if not input[key] == existing[key]: + return False + return True + + +def main(): + module = AnsibleModule( + argument_spec=dict( + api_url=dict(required=True), + access_token=dict(required=False, no_log=True), + private_token=dict(required=False, no_log=True), + project=dict(required=True), + hook_url=dict(required=True), + state=dict(default='present', choices=['present', 'absent']), + push_events=dict(default='yes', type='bool'), + issues_events=dict(default='no', type='bool'), + merge_requests_events=dict(default='no', type='bool'), + tag_push_events=dict(default='no', type='bool'), + note_events=dict(default='no', type='bool'), + job_events=dict(default='no', type='bool'), + pipeline_events=dict(default='no', type='bool'), + wiki_page_events=dict(default='no', type='bool'), + enable_ssl_verification=dict(default='no', type='bool'), + token=dict(required=False, no_log=True), + ), + mutually_exclusive=[ + ['access_token', 'private_token'] + ], + required_one_of=[ + ['access_token', 'private_token'] + ], + supports_check_mode=True, + ) + + api_url = module.params['api_url'] + access_token = module.params['access_token'] + private_token = module.params['private_token'] + project = module.params['project'] + state = module.params['state'] + + if not access_token and not private_token: + module.fail_json(msg="need either access_token or private_token") + + input = {'url': module.params['hook_url']} + + for key in [ + 'push_events', 'issues_events', 'merge_requests_events', + 'tag_push_events', 'note_events', 'job_events', 'pipeline_events', 'wiki_page_events', + 'enable_ssl_verification', 'token']: + input[key] = module.params[key] + + success, existing = _find(module, api_url, project, input['url'], access_token, private_token) + + if not success: + module.fail_json(msg="failed to list hooks", result=existing) + + if existing: + input['id'] = existing['id'] + + changed = False + success = True + response = None + + if state == 'present': + if not existing or input['token'] or not _are_equivalent(existing, input): + if not module.check_mode: + success, response = _publish(module, api_url, project, input, access_token, private_token) + changed = True + else: + if existing: + if not module.check_mode: + success, response = _delete(module, api_url, project, existing['id'], access_token, private_token) + changed = True + + if success: + module.exit_json(changed=changed, msg='Success', result=response, previous_version=existing) + else: + module.fail_json(msg='Failure', error=response) + +if __name__ == '__main__': + main() diff --git a/test/units/modules/source_control/test_gitlab_hooks.py b/test/units/modules/source_control/test_gitlab_hooks.py new file mode 100755 index 00000000000..2487b963dc3 --- /dev/null +++ b/test/units/modules/source_control/test_gitlab_hooks.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018 Marcus Watkins +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from ansible.compat.tests.mock import patch +from ansible.modules.source_control import gitlab_hooks +from ansible.module_utils._text import to_bytes +from ansible.module_utils import basic + +import pytest +import json + +fake_server_state = [ + { + "id": 1, + "url": "https://notification-server.example.com/gitlab-hook", + "project_id": 10, + "push_events": True, + "issues_events": True, + "merge_requests_events": True, + "tag_push_events": True, + "note_events": True, + "job_events": True, + "pipeline_events": True, + "wiki_page_events": True, + "enable_ssl_verification": True, + "created_at": "2012-10-12T17:04:47Z" + }, +] + + +def set_module_args(args): + """prepare arguments so that they will be picked up during module creation""" + args = json.dumps({'ANSIBLE_MODULE_ARGS': args}) + basic._ANSIBLE_ARGS = to_bytes(args) + + +class FakeReader: + def __init__(self, object): + self.content = json.dumps(object, sort_keys=True) + + def read(self): + return self.content + + +class AnsibleExitJson(Exception): + """Exception class to be raised by module.exit_json and caught by the test case""" + pass + + +class AnsibleFailJson(Exception): + """Exception class to be raised by module.fail_json and caught by the test case""" + pass + + +def exit_json(*args, **kwargs): + """function to patch over exit_json; package return data into an exception""" + if 'changed' not in kwargs: + kwargs['changed'] = False + raise AnsibleExitJson(kwargs) + + +def fail_json(*args, **kwargs): + """function to patch over fail_json; package return data into an exception""" + kwargs['failed'] = True + raise AnsibleFailJson(kwargs) + + +@pytest.fixture +def fetch_url_mock(mocker): + return mocker.patch('ansible.module_utils.gitlab.fetch_url') + + +@pytest.fixture +def module_mock(mocker): + return mocker.patch.multiple(basic.AnsibleModule, + exit_json=exit_json, + fail_json=fail_json) + + +def test_access_token_output(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://my-ci-server.example.com/gitlab-hook', + 'state': 'absent' + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + first_call = fetch_url_mock.call_args_list[0][1] + assert first_call['url'] == 'https://gitlab.example.com/api/v4/projects/10/hooks' + assert first_call['headers']['Authorization'] == 'Bearer test-access-token' + assert 'Private-Token' not in first_call['headers'] + assert first_call['method'] == 'GET' + + +def test_private_token_output(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'private_token': 'test-private-token', + 'project': 'foo/bar', + 'hook_url': 'https://my-ci-server.example.com/gitlab-hook', + 'state': 'absent' + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + first_call = fetch_url_mock.call_args_list[0][1] + assert first_call['url'] == 'https://gitlab.example.com/api/v4/projects/foo%2Fbar/hooks' + assert first_call['headers']['Private-Token'] == 'test-private-token' + assert 'Authorization' not in first_call['headers'] + assert first_call['method'] == 'GET' + + +def test_bad_http_first_response(capfd, fetch_url_mock, module_mock): + fetch_url_mock.side_effect = [[FakeReader("Permission denied"), {'status': 403}], [FakeReader("Permission denied"), {'status': 403}]] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://my-ci-server.example.com/gitlab-hook', + 'state': 'absent' + }) + with pytest.raises(AnsibleFailJson): + gitlab_hooks.main() + + +def test_bad_http_second_response(capfd, fetch_url_mock, module_mock): + fetch_url_mock.side_effect = [[FakeReader(fake_server_state), {'status': 200}], [FakeReader("Permission denied"), {'status': 403}]] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://my-ci-server.example.com/gitlab-hook', + 'state': 'present' + }) + with pytest.raises(AnsibleFailJson): + gitlab_hooks.main() + + +def test_delete_non_existing(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://my-ci-server.example.com/gitlab-hook', + 'state': 'absent' + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + assert result.value.args[0]['changed'] is False + + +def test_delete_existing(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://notification-server.example.com/gitlab-hook', + 'state': 'absent' + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + second_call = fetch_url_mock.call_args_list[1][1] + + assert second_call['url'] == 'https://gitlab.example.com/api/v4/projects/10/hooks/1' + assert second_call['method'] == 'DELETE' + + assert result.value.args[0]['changed'] is True + + +def test_add_new(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://my-ci-server.example.com/gitlab-hook', + 'state': 'present' + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + second_call = fetch_url_mock.call_args_list[1][1] + + assert second_call['url'] == 'https://gitlab.example.com/api/v4/projects/10/hooks' + assert second_call['method'] == 'POST' + assert second_call['data'] == ('{"enable_ssl_verification": false, "issues_events": false, "job_events": false, ' + '"merge_requests_events": false, "note_events": false, "pipeline_events": false, "push_events": true, "tag_push_events": ' + 'false, "token": null, "url": "https://my-ci-server.example.com/gitlab-hook", "wiki_page_events": false}') + assert result.value.args[0]['changed'] is True + + +def test_update_existing(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://notification-server.example.com/gitlab-hook', + 'push_events': 'yes', + 'issues_events': 'yes', + 'merge_requests_events': 'yes', + 'tag_push_events': 'yes', + 'note_events': 'yes', + 'job_events': 'yes', + 'pipeline_events': 'yes', + 'wiki_page_events': 'no', + 'enable_ssl_verification': 'yes', + 'state': 'present' + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + second_call = fetch_url_mock.call_args_list[1][1] + + assert second_call['url'] == 'https://gitlab.example.com/api/v4/projects/10/hooks/1' + assert second_call['method'] == 'PUT' + assert second_call['data'] == ('{"enable_ssl_verification": true, "issues_events": true, "job_events": true, ' + '"merge_requests_events": true, "note_events": true, "pipeline_events": true, "push_events": true, "tag_push_events": ' + 'true, "token": null, "url": "https://notification-server.example.com/gitlab-hook", "wiki_page_events": false}') + assert result.value.args[0]['changed'] is True + + +def test_unchanged_existing(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://notification-server.example.com/gitlab-hook', + 'push_events': 'yes', + 'issues_events': 'yes', + 'merge_requests_events': 'yes', + 'tag_push_events': 'yes', + 'note_events': 'yes', + 'job_events': 'yes', + 'pipeline_events': 'yes', + 'wiki_page_events': 'yes', + 'enable_ssl_verification': 'yes', + 'state': 'present' + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + assert result.value.args[0]['changed'] is False + assert fetch_url_mock.call_count == 1 + + +def test_unchanged_existing_with_token(capfd, fetch_url_mock, module_mock): + fetch_url_mock.return_value = [FakeReader(fake_server_state), {'status': 200}] + set_module_args({ + 'api_url': 'https://gitlab.example.com/api', + 'access_token': 'test-access-token', + 'project': '10', + 'hook_url': 'https://notification-server.example.com/gitlab-hook', + 'push_events': 'yes', + 'issues_events': 'yes', + 'merge_requests_events': 'yes', + 'tag_push_events': 'yes', + 'note_events': 'yes', + 'job_events': 'yes', + 'pipeline_events': 'yes', + 'wiki_page_events': 'yes', + 'enable_ssl_verification': 'yes', + 'state': 'present', + 'token': 'secret-token', + }) + with pytest.raises(AnsibleExitJson) as result: + gitlab_hooks.main() + + second_call = fetch_url_mock.call_args_list[1][1] + + assert second_call['url'] == 'https://gitlab.example.com/api/v4/projects/10/hooks/1' + assert second_call['method'] == 'PUT' + assert second_call['data'] == ('{"enable_ssl_verification": true, "issues_events": true, "job_events": true, ' + '"merge_requests_events": true, "note_events": true, "pipeline_events": true, "push_events": true, ' + '"tag_push_events": true, "token": "secret-token", "url": "https://notification-server.example.com/gitlab-hook", ' + '"wiki_page_events": true}') + assert result.value.args[0]['changed'] is True