From 7734430f23a8c2472583543d4e4919aa37bf632f Mon Sep 17 00:00:00 2001 From: Werner Dijkerman Date: Sat, 17 Jul 2021 08:49:09 +0200 Subject: [PATCH] Added module for creating protected branches (#2781) * Added module for creating protected branches * Applied some changes due to comments and added a test that currently fails * Changing no_access to nobody due to comment on PR * Changing the description to clarify it a bit more * Added working tests for module 'gitlab_protected_branch' * Fixing lint issues * Added doc that minimum of v2.3.0 is needed to work correctly * Fixed the requirements notation * Check the version of the module * Hopefully fixed the tests by skipping it when lower version of 2.3.0 is installed * Fix lint issues * Applying changes due to comments in PR * Remove commented code * Removing the trailing dot ... Co-authored-by: jenkins-x-bot Co-authored-by: Werner Dijkerman --- plugins/modules/gitlab_protected_branch.py | 1 + .../gitlab/gitlab_protected_branch.py | 201 ++++++++++++++++++ .../modules/source_control/gitlab/gitlab.py | 38 +++- .../gitlab/test_gitlab_protected_branch.py | 81 +++++++ 4 files changed, 319 insertions(+), 2 deletions(-) create mode 120000 plugins/modules/gitlab_protected_branch.py create mode 100644 plugins/modules/source_control/gitlab/gitlab_protected_branch.py create mode 100644 tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py diff --git a/plugins/modules/gitlab_protected_branch.py b/plugins/modules/gitlab_protected_branch.py new file mode 120000 index 0000000000..7af5b500ce --- /dev/null +++ b/plugins/modules/gitlab_protected_branch.py @@ -0,0 +1 @@ +source_control/gitlab/gitlab_protected_branch.py \ No newline at end of file diff --git a/plugins/modules/source_control/gitlab/gitlab_protected_branch.py b/plugins/modules/source_control/gitlab/gitlab_protected_branch.py new file mode 100644 index 0000000000..f61f2b9fa1 --- /dev/null +++ b/plugins/modules/source_control/gitlab/gitlab_protected_branch.py @@ -0,0 +1,201 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2021, Werner Dijkerman (ikben@werner-dijkerman.nl) +# 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 + +DOCUMENTATION = ''' +module: gitlab_protected_branch +short_description: (un)Marking existing branches for protection +version_added: 3.4.0 +description: + - (un)Marking existing branches for protection. +author: + - "Werner Dijkerman (@dj-wasabi)" +requirements: + - python >= 2.7 + - python-gitlab >= 2.3.0 +extends_documentation_fragment: +- community.general.auth_basic + +options: + state: + description: + - Create or delete proteced branch. + default: present + type: str + choices: ["present", "absent"] + api_token: + description: + - GitLab access token with API permissions. + required: true + type: str + project: + description: + - The path and name of the project. + required: true + type: str + name: + description: + - The name of the branch that needs to be protected. + - Can make use a wildcard charachter for like C(production/*) or just have C(main) or C(develop) as value. + required: true + type: str + merge_access_levels: + description: + - Access levels allowed to merge. + default: maintainer + type: str + choices: ["maintainer", "developer", "nobody"] + push_access_level: + description: + - Access levels allowed to push. + default: maintainer + type: str + choices: ["maintainer", "developer", "nobody"] +''' + + +EXAMPLES = ''' +- name: Create protected branch on main + community.general.gitlab_protected_branch: + api_url: https://gitlab.com + api_token: secret_access_token + project: "dj-wasabi/collection.general" + name: main + merge_access_levels: maintainer + push_access_level: nobody + +''' + +RETURN = ''' +''' + +import traceback + +from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.api import basic_auth_argument_spec +from distutils.version import LooseVersion + +GITLAB_IMP_ERR = None +try: + import gitlab + HAS_GITLAB_PACKAGE = True +except Exception: + GITLAB_IMP_ERR = traceback.format_exc() + HAS_GITLAB_PACKAGE = False + +from ansible_collections.community.general.plugins.module_utils.gitlab import gitlabAuthentication + + +class GitlabProtectedBranch(object): + + def __init__(self, module, project, gitlab_instance): + self.repo = gitlab_instance + self._module = module + self.project = self.get_project(project) + self.ACCESS_LEVEL = { + 'nobody': gitlab.NO_ACCESS, + 'developer': gitlab.DEVELOPER_ACCESS, + 'maintainer': gitlab.MAINTAINER_ACCESS + } + + def get_project(self, project_name): + return self.repo.projects.get(project_name) + + def protected_branch_exist(self, name): + try: + return self.project.protectedbranches.get(name) + except Exception as e: + return False + + def create_protected_branch(self, name, merge_access_levels, push_access_level): + if self._module.check_mode: + return True + merge = self.ACCESS_LEVEL[merge_access_levels] + push = self.ACCESS_LEVEL[push_access_level] + self.project.protectedbranches.create({ + 'name': name, + 'merge_access_level': merge, + 'push_access_level': push + }) + + def compare_protected_branch(self, name, merge_access_levels, push_access_level): + configured_merge = self.ACCESS_LEVEL[merge_access_levels] + configured_push = self.ACCESS_LEVEL[push_access_level] + current = self.protected_branch_exist(name=name) + current_merge = current.merge_access_levels[0]['access_level'] + current_push = current.push_access_levels[0]['access_level'] + if current: + if current.name == name and current_merge == configured_merge and current_push == configured_push: + return True + return False + + def delete_protected_branch(self, name): + if self._module.check_mode: + return True + return self.project.protectedbranches.delete(name) + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update( + api_token=dict(type='str', required=True, no_log=True), + project=dict(type='str', required=True), + name=dict(type='str', required=True), + merge_access_levels=dict(type='str', default="maintainer", choices=["maintainer", "developer", "nobody"]), + push_access_level=dict(type='str', default="maintainer", choices=["maintainer", "developer", "nobody"]), + state=dict(type='str', default="present", choices=["absent", "present"]), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[ + ['api_username', 'api_token'], + ['api_password', 'api_token'], + ], + required_together=[ + ['api_username', 'api_password'], + ], + required_one_of=[ + ['api_username', 'api_token'] + ], + supports_check_mode=True + ) + + project = module.params['project'] + name = module.params['name'] + merge_access_levels = module.params['merge_access_levels'] + push_access_level = module.params['push_access_level'] + state = module.params['state'] + + if not HAS_GITLAB_PACKAGE: + module.fail_json(msg=missing_required_lib("python-gitlab"), exception=GITLAB_IMP_ERR) + + gitlab_version = gitlab.__version__ + if LooseVersion(gitlab_version) < LooseVersion('2.3.0'): + module.fail_json(msg="community.general.gitlab_proteched_branch requires python-gitlab Python module >= 2.3.0 (installed version: [%s])." + " Please upgrade python-gitlab to version 2.3.0 or above." % gitlab_version) + + gitlab_instance = gitlabAuthentication(module) + this_gitlab = GitlabProtectedBranch(module=module, project=project, gitlab_instance=gitlab_instance) + + p_branch = this_gitlab.protected_branch_exist(name=name) + if not p_branch and state == "present": + this_gitlab.create_protected_branch(name=name, merge_access_levels=merge_access_levels, push_access_level=push_access_level) + module.exit_json(changed=True, msg="Created the proteched branch.") + elif p_branch and state == "present": + if not this_gitlab.compare_protected_branch(name, merge_access_levels, push_access_level): + this_gitlab.delete_protected_branch(name=name) + this_gitlab.create_protected_branch(name=name, merge_access_levels=merge_access_levels, push_access_level=push_access_level) + module.exit_json(changed=True, msg="Recreated the proteched branch.") + elif p_branch and state == "absent": + this_gitlab.delete_protected_branch(name=name) + module.exit_json(changed=True, msg="Deleted the proteched branch.") + module.exit_json(changed=False, msg="No changes are needed.") + + +if __name__ == '__main__': + main() diff --git a/tests/unit/plugins/modules/source_control/gitlab/gitlab.py b/tests/unit/plugins/modules/source_control/gitlab/gitlab.py index eb8099d37b..5feff78b43 100644 --- a/tests/unit/plugins/modules/source_control/gitlab/gitlab.py +++ b/tests/unit/plugins/modules/source_control/gitlab/gitlab.py @@ -13,7 +13,7 @@ from httmock import urlmatch # noqa from ansible_collections.community.general.tests.unit.compat import unittest -from gitlab import Gitlab +import gitlab class FakeAnsibleModule(object): @@ -33,7 +33,7 @@ class GitlabModuleTestCase(unittest.TestCase): self.mock_module = FakeAnsibleModule() - self.gitlab_instance = Gitlab("http://localhost", private_token="private_token", api_version=4) + self.gitlab_instance = gitlab.Gitlab("http://localhost", private_token="private_token", api_version=4) # Python 2.7+ is needed for python-gitlab @@ -45,6 +45,14 @@ def python_version_match_requirement(): return sys.version_info >= GITLAB_MINIMUM_PYTHON_VERSION +def python_gitlab_module_version(): + return gitlab.__version__ + + +def python_gitlab_version_match_requirement(): + return "2.3.0" + + # Skip unittest test case if python version don't match requirement def unitest_python_version_check_requirement(unittest_testcase): if not python_version_match_requirement(): @@ -467,6 +475,32 @@ def resp_delete_project(url, request): return response(204, content, headers, None, 5, request) +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/protected_branches/master", method="get") +def resp_get_protected_branch(url, request): + headers = {'content-type': 'application/json'} + content = ('{"id": 1, "name": "master", "push_access_levels": [{"access_level": 40, "access_level_description": "Maintainers"}],' + '"merge_access_levels": [{"access_level": 40, "access_level_description": "Maintainers"}],' + '"allow_force_push":false, "code_owner_approval_required": false}') + content = content.encode("utf-8") + return response(200, content, headers, None, 5, request) + + +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/protected_branches/master", method="get") +def resp_get_protected_branch_not_exist(url, request): + headers = {'content-type': 'application/json'} + content = ('') + content = content.encode("utf-8") + return response(404, content, headers, None, 5, request) + + +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/protected_branches/master", method="delete") +def resp_delete_protected_branch(url, request): + headers = {'content-type': 'application/json'} + content = ('') + content = content.encode("utf-8") + return response(204, content, headers, None, 5, request) + + ''' HOOK API ''' diff --git a/tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py b/tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py new file mode 100644 index 0000000000..026efb19d8 --- /dev/null +++ b/tests/unit/plugins/modules/source_control/gitlab/test_gitlab_protected_branch.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Guillaume Martinez (lunik@tiwabbit.fr) +# 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 pytest +from distutils.version import LooseVersion + +from ansible_collections.community.general.plugins.modules.source_control.gitlab.gitlab_protected_branch import GitlabProtectedBranch + + +def _dummy(x): + """Dummy function. Only used as a placeholder for toplevel definitions when the test is going + to be skipped anyway""" + return x + + +pytestmark = [] +try: + from .gitlab import (GitlabModuleTestCase, + python_version_match_requirement, python_gitlab_module_version, + python_gitlab_version_match_requirement, + resp_get_protected_branch, resp_get_project_by_name, + resp_get_protected_branch_not_exist, + resp_delete_protected_branch, resp_get_user) + + # GitLab module requirements + if python_version_match_requirement(): + from gitlab.v4.objects import Project + gitlab_req_version = python_gitlab_version_match_requirement() + gitlab_module_version = python_gitlab_module_version() + if LooseVersion(gitlab_module_version) < LooseVersion(gitlab_req_version): + pytestmark.append(pytest.mark.skip("Could not load gitlab module required for testing (Wrong version)")) +except ImportError: + pytestmark.append(pytest.mark.skip("Could not load gitlab module required for testing")) + +# Unit tests requirements +try: + from httmock import with_httmock # noqa +except ImportError: + pytestmark.append(pytest.mark.skip("Could not load httmock module required for testing")) + with_httmock = _dummy + + +class TestGitlabProtectedBranch(GitlabModuleTestCase): + @with_httmock(resp_get_project_by_name) + @with_httmock(resp_get_user) + def setUp(self): + super(TestGitlabProtectedBranch, self).setUp() + + self.gitlab_instance.user = self.gitlab_instance.users.get(1) + self.moduleUtil = GitlabProtectedBranch(module=self.mock_module, project="foo-bar/diaspora-client", gitlab_instance=self.gitlab_instance) + + @with_httmock(resp_get_protected_branch) + def test_protected_branch_exist(self): + rvalue = self.moduleUtil.protected_branch_exist(name="master") + self.assertEqual(rvalue.name, "master") + + @with_httmock(resp_get_protected_branch_not_exist) + def test_protected_branch_exist_not_exist(self): + rvalue = self.moduleUtil.protected_branch_exist(name="master") + self.assertEqual(rvalue, False) + + @with_httmock(resp_get_protected_branch) + def test_compare_protected_branch(self): + rvalue = self.moduleUtil.compare_protected_branch(name="master", merge_access_levels="maintainer", push_access_level="maintainer") + self.assertEqual(rvalue, True) + + @with_httmock(resp_get_protected_branch) + def test_compare_protected_branch_different_settings(self): + rvalue = self.moduleUtil.compare_protected_branch(name="master", merge_access_levels="developer", push_access_level="maintainer") + self.assertEqual(rvalue, False) + + @with_httmock(resp_get_protected_branch) + @with_httmock(resp_delete_protected_branch) + def test_delete_protected_branch(self): + rvalue = self.moduleUtil.delete_protected_branch(name="master") + self.assertEqual(rvalue, None)