diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index dc1ad3989c..a9e871d192 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -588,6 +588,10 @@ files: maintainers: SamyCoenen $modules/gitlab_user.py: maintainers: LennertMertens stgrace + $modules/gitlab_group_access_token.py: + maintainers: pixslx + $modules/gitlab_project_access_token.py: + maintainers: pixslx $modules/grove.py: maintainers: zimbatm $modules/gunicorn.py: diff --git a/plugins/modules/gitlab_group_access_token.py b/plugins/modules/gitlab_group_access_token.py new file mode 100644 index 0000000000..85bba205db --- /dev/null +++ b/plugins/modules/gitlab_group_access_token.py @@ -0,0 +1,320 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Zoran Krleza (zoran.krleza@true-north.hr) +# Based on code: +# Copyright (c) 2019, Guillaume Martinez (lunik@tiwabbit.fr) +# Copyright (c) 2018, Marcus Watkins +# Copyright (c) 2013, Phillip Gentry +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +module: gitlab_group_access_token +short_description: Manages GitLab group access tokens +version_added: 8.4.0 +description: + - Creates and revokes group access tokens. +author: + - Zoran Krleza (@pixslx) +requirements: + - python-gitlab >= 3.1.0 +extends_documentation_fragment: + - community.general.auth_basic + - community.general.gitlab + - community.general.attributes +notes: + - Access tokens can not be changed. If a parameter needs to be changed, an acceess token has to be recreated. + Whether tokens will be recreated is controlled by the O(recreate) option, which defaults to V(never). + - Token string is contained in the result only when access token is created or recreated. It can not be fetched afterwards. + - Token matching is done by comparing O(name) option. + +attributes: + check_mode: + support: full + diff_mode: + support: none + +options: + group: + description: + - ID or full path of group in the form of group/subgroup. + required: true + type: str + name: + description: + - Access token's name. + required: true + type: str + scopes: + description: + - Scope of the access token. + required: true + type: list + elements: str + aliases: ["scope"] + choices: ["api", "read_api", "read_registry", "write_registry", "read_repository", "write_repository", "create_runner", "ai_features", "k8s_proxy"] + access_level: + description: + - Access level of the access token. + type: str + default: maintainer + choices: ["guest", "reporter", "developer", "maintainer", "owner"] + expires_at: + description: + - Expiration date of the access token in C(YYYY-MM-DD) format. + - Make sure to quote this value in YAML to ensure it is kept as a string and not interpreted as a YAML date. + type: str + required: true + recreate: + description: + - Whether the access token will be recreated if it already exists. + - When V(never) the token will never be recreated. + - When V(always) the token will always be recreated. + - When V(state_change) the token will be recreated if there is a difference between desired state and actual state. + type: str + choices: ["never", "always", "state_change"] + default: never + state: + description: + - When V(present) the access token will be added to the group if it does not exist. + - When V(absent) it will be removed from the group if it exists. + default: present + type: str + choices: [ "present", "absent" ] +''' + +EXAMPLES = r''' +- name: "Creating a group access token" + community.general.gitlab_group_access_token: + api_url: https://gitlab.example.com/ + api_token: "somegitlabapitoken" + group: "my_group/my_subgroup" + name: "group_token" + expires_at: "2024-12-31" + access_level: developer + scopes: + - api + - read_api + - read_repository + - write_repository + state: present + +- name: "Revoking a group access token" + community.general.gitlab_group_access_token: + api_url: https://gitlab.example.com/ + api_token: "somegitlabapitoken" + group: "my_group/my_group" + name: "group_token" + expires_at: "2024-12-31" + scopes: + - api + - read_api + - read_repository + - write_repository + state: absent + +- name: "Change (recreate) existing token if its actual state is different than desired state" + community.general.gitlab_group_access_token: + api_url: https://gitlab.example.com/ + api_token: "somegitlabapitoken" + group: "my_group/my_group" + name: "group_token" + expires_at: "2024-12-31" + scopes: + - api + - read_api + - read_repository + - write_repository + recreate: state_change + state: present +''' + +RETURN = r''' +access_token: + description: + - API object. + - Only contains the value of the token if the token was created or recreated. + returned: success and O(state=present) + type: dict +''' + +from datetime import datetime + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.general.plugins.module_utils.gitlab import ( + auth_argument_spec, find_group, gitlab_authentication, gitlab +) + +ACCESS_LEVELS = dict(guest=10, reporter=20, developer=30, maintainer=40, owner=50) + + +class GitLabGroupAccessToken(object): + def __init__(self, module, gitlab_instance): + self._module = module + self._gitlab = gitlab_instance + self.access_token_object = None + + ''' + @param project Project Object + @param group Group Object + @param arguments Attributes of the access_token + ''' + def create_access_token(self, group, arguments): + changed = False + if self._module.check_mode: + return True + + try: + self.access_token_object = group.access_tokens.create(arguments) + changed = True + except (gitlab.exceptions.GitlabCreateError) as e: + self._module.fail_json(msg="Failed to create access token: %s " % to_native(e)) + + return changed + + ''' + @param project Project object + @param group Group Object + @param name of the access token + ''' + def find_access_token(self, group, name): + access_tokens = group.access_tokens.list(all=True) + for access_token in access_tokens: + if (access_token.name == name): + self.access_token_object = access_token + return False + return False + + def revoke_access_token(self): + if self._module.check_mode: + return True + + changed = False + try: + self.access_token_object.delete() + changed = True + except (gitlab.exceptions.GitlabCreateError) as e: + self._module.fail_json(msg="Failed to revoke access token: %s " % to_native(e)) + + return changed + + def access_tokens_equal(self): + if self.access_token_object.name != self._module.params['name']: + return False + if self.access_token_object.scopes != self._module.params['scopes']: + return False + if self.access_token_object.access_level != ACCESS_LEVELS[self._module.params['access_level']]: + return False + if self.access_token_object.expires_at != self._module.params['expires_at']: + return False + return True + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update(auth_argument_spec()) + argument_spec.update(dict( + state=dict(type='str', default="present", choices=["absent", "present"]), + group=dict(type='str', required=True), + name=dict(type='str', required=True), + scopes=dict(type='list', + required=True, + aliases=['scope'], + elements='str', + choices=['api', + 'read_api', + 'read_registry', + 'write_registry', + 'read_repository', + 'write_repository', + 'create_runner', + 'ai_features', + 'k8s_proxy']), + access_level=dict(type='str', required=False, default='maintainer', choices=['guest', 'reporter', 'developer', 'maintainer', 'owner']), + expires_at=dict(type='str', required=True), + recreate=dict(type='str', default='never', choices=['never', 'always', 'state_change']) + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[ + ['api_username', 'api_token'], + ['api_username', 'api_oauth_token'], + ['api_username', 'api_job_token'], + ['api_token', 'api_oauth_token'], + ['api_token', 'api_job_token'] + ], + required_together=[ + ['api_username', 'api_password'] + ], + required_one_of=[ + ['api_username', 'api_token', 'api_oauth_token', 'api_job_token'] + ], + supports_check_mode=True + ) + + state = module.params['state'] + group_identifier = module.params['group'] + name = module.params['name'] + scopes = module.params['scopes'] + access_level_str = module.params['access_level'] + expires_at = module.params['expires_at'] + recreate = module.params['recreate'] + + access_level = ACCESS_LEVELS[access_level_str] + + try: + datetime.strptime(expires_at, '%Y-%m-%d') + except ValueError: + module.fail_json(msg="Argument expires_at is not in required format YYYY-MM-DD") + + gitlab_instance = gitlab_authentication(module) + + gitlab_access_token = GitLabGroupAccessToken(module, gitlab_instance) + + group = find_group(gitlab_instance, group_identifier) + if group is None: + module.fail_json(msg="Failed to create access token: group %s does not exists" % group_identifier) + + gitlab_access_token_exists = False + gitlab_access_token.find_access_token(group, name) + if gitlab_access_token.access_token_object is not None: + gitlab_access_token_exists = True + + if state == 'absent': + if gitlab_access_token_exists: + gitlab_access_token.revoke_access_token() + module.exit_json(changed=True, msg="Successfully deleted access token %s" % name) + else: + module.exit_json(changed=False, msg="Access token does not exists") + + if state == 'present': + if gitlab_access_token_exists: + if gitlab_access_token.access_tokens_equal(): + if recreate == 'always': + gitlab_access_token.revoke_access_token() + gitlab_access_token.create_access_token(group, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) + module.exit_json(changed=True, msg="Successfully recreated access token", access_token=gitlab_access_token.access_token_object._attrs) + else: + module.exit_json(changed=False, msg="Access token already exists", access_token=gitlab_access_token.access_token_object._attrs) + else: + if recreate == 'never': + module.fail_json(msg="Access token already exists and its state is different. It can not be updated without recreating.") + else: + gitlab_access_token.revoke_access_token() + gitlab_access_token.create_access_token(group, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) + module.exit_json(changed=True, msg="Successfully recreated access token", access_token=gitlab_access_token.access_token_object._attrs) + else: + gitlab_access_token.create_access_token(group, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) + module.exit_json(changed=True, msg="Successfully created access token", access_token=gitlab_access_token.access_token_object._attrs) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/gitlab_project_access_token.py b/plugins/modules/gitlab_project_access_token.py new file mode 100644 index 0000000000..e692a30577 --- /dev/null +++ b/plugins/modules/gitlab_project_access_token.py @@ -0,0 +1,318 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024, Zoran Krleza (zoran.krleza@true-north.hr) +# Based on code: +# Copyright (c) 2019, Guillaume Martinez (lunik@tiwabbit.fr) +# Copyright (c) 2018, Marcus Watkins +# Copyright (c) 2013, Phillip Gentry +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = r''' +module: gitlab_project_access_token +short_description: Manages GitLab project access tokens +version_added: 8.4.0 +description: + - Creates and revokes project access tokens. +author: + - Zoran Krleza (@pixslx) +requirements: + - python-gitlab >= 3.1.0 +extends_documentation_fragment: + - community.general.auth_basic + - community.general.gitlab + - community.general.attributes +notes: + - Access tokens can not be changed. If a parameter needs to be changed, an acceess token has to be recreated. + Whether tokens will be recreated is controlled by the O(recreate) option, which defaults to V(never). + - Token string is contained in the result only when access token is created or recreated. It can not be fetched afterwards. + - Token matching is done by comparing O(name) option. + +attributes: + check_mode: + support: full + diff_mode: + support: none + +options: + project: + description: + - ID or full path of project in the form of group/name. + required: true + type: str + name: + description: + - Access token's name. + required: true + type: str + scopes: + description: + - Scope of the access token. + required: true + type: list + elements: str + aliases: ["scope"] + choices: ["api", "read_api", "read_registry", "write_registry", "read_repository", "write_repository", "create_runner", "ai_features", "k8s_proxy"] + access_level: + description: + - Access level of the access token. + type: str + default: maintainer + choices: ["guest", "reporter", "developer", "maintainer", "owner"] + expires_at: + description: + - Expiration date of the access token in C(YYYY-MM-DD) format. + - Make sure to quote this value in YAML to ensure it is kept as a string and not interpreted as a YAML date. + type: str + required: true + recreate: + description: + - Whether the access token will be recreated if it already exists. + - When V(never) the token will never be recreated. + - When V(always) the token will always be recreated. + - When V(state_change) the token will be recreated if there is a difference between desired state and actual state. + type: str + choices: ["never", "always", "state_change"] + default: never + state: + description: + - When V(present) the access token will be added to the project if it does not exist. + - When V(absent) it will be removed from the project if it exists. + default: present + type: str + choices: [ "present", "absent" ] +''' + +EXAMPLES = r''' +- name: "Creating a project access token" + community.general.gitlab_project_access_token: + api_url: https://gitlab.example.com/ + api_token: "somegitlabapitoken" + project: "my_group/my_project" + name: "project_token" + expires_at: "2024-12-31" + access_level: developer + scopes: + - api + - read_api + - read_repository + - write_repository + state: present + +- name: "Revoking a project access token" + community.general.gitlab_project_access_token: + api_url: https://gitlab.example.com/ + api_token: "somegitlabapitoken" + project: "my_group/my_project" + name: "project_token" + expires_at: "2024-12-31" + scopes: + - api + - read_api + - read_repository + - write_repository + state: absent + +- name: "Change (recreate) existing token if its actual state is different than desired state" + community.general.gitlab_project_access_token: + api_url: https://gitlab.example.com/ + api_token: "somegitlabapitoken" + project: "my_group/my_project" + name: "project_token" + expires_at: "2024-12-31" + scopes: + - api + - read_api + - read_repository + - write_repository + recreate: state_change + state: present +''' + +RETURN = r''' +access_token: + description: + - API object. + - Only contains the value of the token if the token was created or recreated. + returned: success and O(state=present) + type: dict +''' + +from datetime import datetime + +from ansible.module_utils.api import basic_auth_argument_spec +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.general.plugins.module_utils.gitlab import ( + auth_argument_spec, find_project, gitlab_authentication, gitlab +) + +ACCESS_LEVELS = dict(guest=10, reporter=20, developer=30, maintainer=40, owner=50) + + +class GitLabProjectAccessToken(object): + def __init__(self, module, gitlab_instance): + self._module = module + self._gitlab = gitlab_instance + self.access_token_object = None + + ''' + @param project Project Object + @param arguments Attributes of the access_token + ''' + def create_access_token(self, project, arguments): + changed = False + if self._module.check_mode: + return True + + try: + self.access_token_object = project.access_tokens.create(arguments) + changed = True + except (gitlab.exceptions.GitlabCreateError) as e: + self._module.fail_json(msg="Failed to create access token: %s " % to_native(e)) + + return changed + + ''' + @param project Project object + @param name of the access token + ''' + def find_access_token(self, project, name): + access_tokens = project.access_tokens.list(all=True) + for access_token in access_tokens: + if (access_token.name == name): + self.access_token_object = access_token + return False + return False + + def revoke_access_token(self): + if self._module.check_mode: + return True + + changed = False + try: + self.access_token_object.delete() + changed = True + except (gitlab.exceptions.GitlabCreateError) as e: + self._module.fail_json(msg="Failed to revoke access token: %s " % to_native(e)) + + return changed + + def access_tokens_equal(self): + if self.access_token_object.name != self._module.params['name']: + return False + if self.access_token_object.scopes != self._module.params['scopes']: + return False + if self.access_token_object.access_level != ACCESS_LEVELS[self._module.params['access_level']]: + return False + if self.access_token_object.expires_at != self._module.params['expires_at']: + return False + return True + + +def main(): + argument_spec = basic_auth_argument_spec() + argument_spec.update(auth_argument_spec()) + argument_spec.update(dict( + state=dict(type='str', default="present", choices=["absent", "present"]), + project=dict(type='str', required=True), + name=dict(type='str', required=True), + scopes=dict(type='list', + required=True, + aliases=['scope'], + elements='str', + choices=['api', + 'read_api', + 'read_registry', + 'write_registry', + 'read_repository', + 'write_repository', + 'create_runner', + 'ai_features', + 'k8s_proxy']), + access_level=dict(type='str', required=False, default='maintainer', choices=['guest', 'reporter', 'developer', 'maintainer', 'owner']), + expires_at=dict(type='str', required=True), + recreate=dict(type='str', default='never', choices=['never', 'always', 'state_change']) + )) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[ + ['api_username', 'api_token'], + ['api_username', 'api_oauth_token'], + ['api_username', 'api_job_token'], + ['api_token', 'api_oauth_token'], + ['api_token', 'api_job_token'] + ], + required_together=[ + ['api_username', 'api_password'] + ], + required_one_of=[ + ['api_username', 'api_token', 'api_oauth_token', 'api_job_token'] + ], + supports_check_mode=True + ) + + state = module.params['state'] + project_identifier = module.params['project'] + name = module.params['name'] + scopes = module.params['scopes'] + access_level_str = module.params['access_level'] + expires_at = module.params['expires_at'] + recreate = module.params['recreate'] + + access_level = ACCESS_LEVELS[access_level_str] + + try: + datetime.strptime(expires_at, '%Y-%m-%d') + except ValueError: + module.fail_json(msg="Argument expires_at is not in required format YYYY-MM-DD") + + gitlab_instance = gitlab_authentication(module) + + gitlab_access_token = GitLabProjectAccessToken(module, gitlab_instance) + + project = find_project(gitlab_instance, project_identifier) + if project is None: + module.fail_json(msg="Failed to create access token: project %s does not exists" % project_identifier) + + gitlab_access_token_exists = False + gitlab_access_token.find_access_token(project, name) + if gitlab_access_token.access_token_object is not None: + gitlab_access_token_exists = True + + if state == 'absent': + if gitlab_access_token_exists: + gitlab_access_token.revoke_access_token() + module.exit_json(changed=True, msg="Successfully deleted access token %s" % name) + else: + module.exit_json(changed=False, msg="Access token does not exists") + + if state == 'present': + if gitlab_access_token_exists: + if gitlab_access_token.access_tokens_equal(): + if recreate == 'always': + gitlab_access_token.revoke_access_token() + gitlab_access_token.create_access_token(project, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) + module.exit_json(changed=True, msg="Successfully recreated access token", access_token=gitlab_access_token.access_token_object._attrs) + else: + module.exit_json(changed=False, msg="Access token already exists", access_token=gitlab_access_token.access_token_object._attrs) + else: + if recreate == 'never': + module.fail_json(msg="Access token already exists and its state is different. It can not be updated without recreating.") + else: + gitlab_access_token.revoke_access_token() + gitlab_access_token.create_access_token(project, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) + module.exit_json(changed=True, msg="Successfully recreated access token", access_token=gitlab_access_token.access_token_object._attrs) + else: + gitlab_access_token.create_access_token(project, {'name': name, 'scopes': scopes, 'access_level': access_level, 'expires_at': expires_at}) + module.exit_json(changed=True, msg="Successfully created access token", access_token=gitlab_access_token.access_token_object._attrs) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/gitlab_group_access_token/aliases b/tests/integration/targets/gitlab_group_access_token/aliases new file mode 100644 index 0000000000..fc0e157c90 --- /dev/null +++ b/tests/integration/targets/gitlab_group_access_token/aliases @@ -0,0 +1,7 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/1 +gitlab/ci +disabled diff --git a/tests/integration/targets/gitlab_group_access_token/defaults/main.yml b/tests/integration/targets/gitlab_group_access_token/defaults/main.yml new file mode 100644 index 0000000000..1b0dab2892 --- /dev/null +++ b/tests/integration/targets/gitlab_group_access_token/defaults/main.yml @@ -0,0 +1,15 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2024, Zoran Krleza +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +gitlab_api_token: +gitlab_api_url: +gitlab_validate_certs: false +gitlab_group_name: +gitlab_token_name: diff --git a/tests/integration/targets/gitlab_group_access_token/tasks/main.yml b/tests/integration/targets/gitlab_group_access_token/tasks/main.yml new file mode 100644 index 0000000000..4e6234238e --- /dev/null +++ b/tests/integration/targets/gitlab_group_access_token/tasks/main.yml @@ -0,0 +1,221 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2024, Zoran Krleza +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Install required libs + pip: + name: python-gitlab + state: present + +- block: + - name: Try to create access token in nonexisting group + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "some_nonexisting_group" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + register: create_pfail_token_status + always: + - name: Assert that token creation in nonexisting group failed + assert: + that: + - create_pfail_token_status is failed + ignore_errors: true + +- block: + - name: Try to create access token with nonvalid expires_at + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "some_nonexisting_group" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-13-01' + access_level: developer + scopes: + - api + - read_api + register: create_efail_token_status + always: + - name: Assert that token creation with invalid expires_at failed + assert: + that: + - create_efail_token_status is failed + ignore_errors: true + +- name: Create access token + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: create_token_status +- name: Assert that token creation with valid arguments is successfull + assert: + that: + - create_token_status is changed + - create_token_status.access_token.token is defined + +- name: Check existing access token recreate=never (default) + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: check_token_status +- name: Assert that token creation without changes and recreate=never succeeds with status not changed + assert: + that: + - check_token_status is not changed + - check_token_status.access_token.token is not defined + +- name: Check existing access token with recreate=state_change + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + recreate: state_change + register: check_recreate_token_status +- name: Assert that token creation without changes and recreate=state_change succeeds with status not changed + assert: + that: + - check_recreate_token_status is not changed + - check_recreate_token_status.access_token.token is not defined + +- block: + - name: Try to change existing access token with recreate=never + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + register: change_token_status + always: + - name: Assert that token change with recreate=never fails + assert: + that: + - change_token_status is failed + ignore_errors: true + +- name: Try to change existing access token with recreate=state_change + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + recreate: state_change + register: change_recreate_token_status +- name: Assert that token change with recreate=state_change succeeds + assert: + that: + - change_recreate_token_status is changed + - change_recreate_token_status.access_token.token is defined + +- name: Try to change existing access token with recreate=always + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + recreate: always + register: change_recreate1_token_status +- name: Assert that token change with recreate=always succeeds + assert: + that: + - change_recreate1_token_status is changed + - change_recreate1_token_status.access_token.token is defined + +- name: Revoke access token + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: absent + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: revoke_token_status +- name: Assert that token revocation succeeds + assert: + that: + - revoke_token_status is changed + +- name: Revoke nonexisting access token + community.general.gitlab_group_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + group: "{{ gitlab_group_name }}" + name: "{{ gitlab_token_name }}" + state: absent + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: revoke_token_status +- name: Assert that token revocation succeeds with status not changed + assert: + that: + - revoke_token_status is not changed \ No newline at end of file diff --git a/tests/integration/targets/gitlab_project_access_token/aliases b/tests/integration/targets/gitlab_project_access_token/aliases new file mode 100644 index 0000000000..fc0e157c90 --- /dev/null +++ b/tests/integration/targets/gitlab_project_access_token/aliases @@ -0,0 +1,7 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +azp/posix/1 +gitlab/ci +disabled diff --git a/tests/integration/targets/gitlab_project_access_token/defaults/main.yml b/tests/integration/targets/gitlab_project_access_token/defaults/main.yml new file mode 100644 index 0000000000..579584d62b --- /dev/null +++ b/tests/integration/targets/gitlab_project_access_token/defaults/main.yml @@ -0,0 +1,15 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2024, Zoran Krleza +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +gitlab_api_token: +gitlab_api_url: +gitlab_validate_certs: false +gitlab_project_name: +gitlab_token_name: diff --git a/tests/integration/targets/gitlab_project_access_token/tasks/main.yml b/tests/integration/targets/gitlab_project_access_token/tasks/main.yml new file mode 100644 index 0000000000..c3125d740b --- /dev/null +++ b/tests/integration/targets/gitlab_project_access_token/tasks/main.yml @@ -0,0 +1,221 @@ +--- +#################################################################### +# WARNING: These are designed specifically for Ansible tests # +# and should not be used as examples of how to write Ansible roles # +#################################################################### + +# Copyright (c) 2024, Zoran Krleza +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Install required libs + pip: + name: python-gitlab + state: present + +- block: + - name: Try to create access token in nonexisting project + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "some_nonexisting_project" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + register: create_pfail_token_status + always: + - name: Assert that token creation in nonexisting project failed + assert: + that: + - create_pfail_token_status is failed + ignore_errors: true + +- block: + - name: Try to create access token with nonvalid expires_at + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "some_nonexisting_project" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-13-01' + access_level: developer + scopes: + - api + - read_api + register: create_efail_token_status + always: + - name: Assert that token creation with invalid expires_at failed + assert: + that: + - create_efail_token_status is failed + ignore_errors: true + +- name: Create access token + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: create_token_status +- name: Assert that token creation with valid arguments is successfull + assert: + that: + - create_token_status is changed + - create_token_status.access_token.token is defined + +- name: Check existing access token recreate=never (default) + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: check_token_status +- name: Assert that token creation without changes and recreate=never succeeds with status not changed + assert: + that: + - check_token_status is not changed + - check_token_status.access_token.token is not defined + +- name: Check existing access token with recreate=state_change + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + recreate: state_change + register: check_recreate_token_status +- name: Assert that token creation without changes and recreate=state_change succeeds with status not changed + assert: + that: + - check_recreate_token_status is not changed + - check_recreate_token_status.access_token.token is not defined + +- block: + - name: Try to change existing access token with recreate=never + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + register: change_token_status + always: + - name: Assert that token change with recreate=never fails + assert: + that: + - change_token_status is failed + ignore_errors: true + +- name: Try to change existing access token with recreate=state_change + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + recreate: state_change + register: change_recreate_token_status +- name: Assert that token change with recreate=state_change succeeds + assert: + that: + - change_recreate_token_status is changed + - change_recreate_token_status.access_token.token is defined + +- name: Try to change existing access token with recreate=always + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: present + expires_at: '2025-01-01' + access_level: developer + scopes: + - api + - read_api + recreate: always + register: change_recreate1_token_status +- name: Assert that token change with recreate=always succeeds + assert: + that: + - change_recreate1_token_status is changed + - change_recreate1_token_status.access_token.token is defined + +- name: Revoke access token + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: absent + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: revoke_token_status +- name: Assert that token revocation succeeds + assert: + that: + - revoke_token_status is changed + +- name: Revoke nonexisting access token + community.general.gitlab_project_access_token: + api_token: "{{ gitlab_api_token }}" + api_url: "{{ gitlab_api_url }}" + validate_certs: "{{ gitlab_validate_certs }}" + project: "{{ gitlab_project_name }}" + name: "{{ gitlab_token_name }}" + state: absent + expires_at: '2024-12-31' + access_level: developer + scopes: + - api + - read_api + register: revoke_token_status +- name: Assert that token revocation succeeds with status not changed + assert: + that: + - revoke_token_status is not changed \ No newline at end of file diff --git a/tests/unit/plugins/modules/gitlab.py b/tests/unit/plugins/modules/gitlab.py index c64d99fff2..7a52dc3552 100644 --- a/tests/unit/plugins/modules/gitlab.py +++ b/tests/unit/plugins/modules/gitlab.py @@ -284,6 +284,36 @@ def resp_delete_group(url, request): return response(204, content, headers, None, 5, request) +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1/access_tokens", method="get") +def resp_list_group_access_tokens(url, request): + headers = {'content-type': 'application/json'} + content = ('[{"user_id" : 1, "scopes" : ["api"], "name" : "token1", "expires_at" : "2021-01-31",' + '"id" : 1, "active" : false, "created_at" : "2021-01-20T22:11:48.151Z", "revoked" : true,' + '"access_level": 40},{"user_id" : 2, "scopes" : ["api"], "name" : "token2", "expires_at" : "2021-02-31",' + '"id" : 2, "active" : true, "created_at" : "2021-02-20T22:11:48.151Z", "revoked" : false,' + '"access_level": 40}]') + content = content.encode("utf-8") + return response(200, content, headers, None, 5, request) + + +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1/access_tokens", method="post") +def resp_create_group_access_tokens(url, request): + headers = {'content-type': 'application/json'} + content = ('{"user_id" : 1, "scopes" : ["api"], "name" : "token1", "expires_at" : "2021-01-31",' + '"id" : 1, "active" : false, "created_at" : "2021-01-20T22:11:48.151Z", "revoked" : true,' + '"access_level": 40, "token": "Der423FErcdv35qEEWc"}') + content = content.encode("utf-8") + return response(201, content, headers, None, 5, request) + + +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/groups/1/access_tokens/1", method="delete") +def resp_revoke_group_access_tokens(url, request): + headers = {'content-type': 'application/json'} + content = ('') + content = content.encode("utf-8") + return response(204, content, headers, None, 5, request) + + ''' GROUP MEMBER API ''' @@ -534,6 +564,36 @@ def resp_delete_protected_branch(url, request): return response(204, content, headers, None, 5, request) +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/access_tokens", method="get") +def resp_list_project_access_tokens(url, request): + headers = {'content-type': 'application/json'} + content = ('[{"user_id" : 1, "scopes" : ["api"], "name" : "token1", "expires_at" : "2021-01-31",' + '"id" : 1, "active" : false, "created_at" : "2021-01-20T22:11:48.151Z", "revoked" : true,' + '"access_level": 40},{"user_id" : 2, "scopes" : ["api"], "name" : "token2", "expires_at" : "2021-02-31",' + '"id" : 2, "active" : true, "created_at" : "2021-02-20T22:11:48.151Z", "revoked" : false,' + '"access_level": 40}]') + content = content.encode("utf-8") + return response(200, content, headers, None, 5, request) + + +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/access_tokens", method="post") +def resp_create_project_access_tokens(url, request): + headers = {'content-type': 'application/json'} + content = ('{"user_id" : 1, "scopes" : ["api"], "name" : "token1", "expires_at" : "2021-01-31",' + '"id" : 1, "active" : false, "created_at" : "2021-01-20T22:11:48.151Z", "revoked" : true,' + '"access_level": 40, "token": "Der423FErcdv35qEEWc"}') + content = content.encode("utf-8") + return response(201, content, headers, None, 5, request) + + +@urlmatch(scheme="http", netloc="localhost", path="/api/v4/projects/1/access_tokens/1", method="delete") +def resp_revoke_project_access_tokens(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/test_gitlab_group_access_token.py b/tests/unit/plugins/modules/test_gitlab_group_access_token.py new file mode 100644 index 0000000000..06af948204 --- /dev/null +++ b/tests/unit/plugins/modules/test_gitlab_group_access_token.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023, Zoran Krleza (zoran.krleza@true-north.hr) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +import gitlab + +from ansible_collections.community.general.plugins.modules.gitlab_group_access_token import GitLabGroupAccessToken + +# python-gitlab 3.1+ is needed for python-gitlab access tokens api +PYTHON_GITLAB_MINIMAL_VERSION = (3, 1) + + +def python_gitlab_version_match_requirement(): + return tuple(map(int, gitlab.__version__.split('.'))) >= PYTHON_GITLAB_MINIMAL_VERSION + + +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, + resp_get_user, + resp_get_group, + resp_list_group_access_tokens, + resp_create_group_access_tokens, + resp_revoke_group_access_tokens) + +except ImportError: + pytestmark.append(pytest.mark.skip("Could not load gitlab module required for testing")) + # Need to set these to something so that we don't fail when parsing + GitlabModuleTestCase = object + resp_list_group_access_tokens = _dummy + resp_create_group_access_tokens = _dummy + resp_revoke_group_access_tokens = _dummy + resp_get_user = _dummy + resp_get_group = _dummy + +# 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 TestGitlabGroupAccessToken(GitlabModuleTestCase): + @with_httmock(resp_get_user) + def setUp(self): + super(TestGitlabGroupAccessToken, self).setUp() + if not python_gitlab_version_match_requirement(): + self.skipTest("python-gitlab %s+ is needed for gitlab_group_access_token" % ",".join(map(str, PYTHON_GITLAB_MINIMAL_VERSION))) + + self.moduleUtil = GitLabGroupAccessToken(module=self.mock_module, gitlab_instance=self.gitlab_instance) + + @with_httmock(resp_get_group) + @with_httmock(resp_list_group_access_tokens) + def test_find_access_token(self): + group = self.gitlab_instance.groups.get(1) + self.assertIsNotNone(group) + + rvalue = self.moduleUtil.find_access_token(group, "token1") + self.assertEqual(rvalue, False) + self.assertIsNotNone(self.moduleUtil.access_token_object) + + @with_httmock(resp_get_group) + @with_httmock(resp_list_group_access_tokens) + def test_find_access_token_negative(self): + groups = self.gitlab_instance.groups.get(1) + self.assertIsNotNone(groups) + + rvalue = self.moduleUtil.find_access_token(groups, "nonexisting") + self.assertEqual(rvalue, False) + self.assertIsNone(self.moduleUtil.access_token_object) + + @with_httmock(resp_get_group) + @with_httmock(resp_create_group_access_tokens) + def test_create_access_token(self): + groups = self.gitlab_instance.groups.get(1) + self.assertIsNotNone(groups) + + rvalue = self.moduleUtil.create_access_token(groups, {'name': "tokenXYZ", 'scopes': ["api"], 'access_level': 20, 'expires_at': "2024-12-31"}) + self.assertEqual(rvalue, True) + self.assertIsNotNone(self.moduleUtil.access_token_object) + + @with_httmock(resp_get_group) + @with_httmock(resp_list_group_access_tokens) + @with_httmock(resp_revoke_group_access_tokens) + def test_revoke_access_token(self): + groups = self.gitlab_instance.groups.get(1) + self.assertIsNotNone(groups) + + rvalue = self.moduleUtil.find_access_token(groups, "token1") + self.assertEqual(rvalue, False) + self.assertIsNotNone(self.moduleUtil.access_token_object) + + rvalue = self.moduleUtil.revoke_access_token() + self.assertEqual(rvalue, True) diff --git a/tests/unit/plugins/modules/test_gitlab_project_access_token.py b/tests/unit/plugins/modules/test_gitlab_project_access_token.py new file mode 100644 index 0000000000..ebc324b889 --- /dev/null +++ b/tests/unit/plugins/modules/test_gitlab_project_access_token.py @@ -0,0 +1,107 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2023, Zoran Krleza (zoran.krleza@true-north.hr) +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest +import gitlab + +from ansible_collections.community.general.plugins.modules.gitlab_project_access_token import GitLabProjectAccessToken + +# python-gitlab 3.1+ is needed for python-gitlab access tokens api +PYTHON_GITLAB_MINIMAL_VERSION = (3, 1) + + +def python_gitlab_version_match_requirement(): + return tuple(map(int, gitlab.__version__.split('.'))) >= PYTHON_GITLAB_MINIMAL_VERSION + + +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, + resp_get_user, + resp_get_project, + resp_list_project_access_tokens, + resp_create_project_access_tokens, + resp_revoke_project_access_tokens) + +except ImportError: + pytestmark.append(pytest.mark.skip("Could not load gitlab module required for testing")) + # Need to set these to something so that we don't fail when parsing + GitlabModuleTestCase = object + resp_list_project_access_tokens = _dummy + resp_create_project_access_tokens = _dummy + resp_revoke_project_access_tokens = _dummy + resp_get_user = _dummy + resp_get_project = _dummy + +# 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 TestGitlabProjectAccessToken(GitlabModuleTestCase): + @with_httmock(resp_get_user) + def setUp(self): + super(TestGitlabProjectAccessToken, self).setUp() + if not python_gitlab_version_match_requirement(): + self.skipTest("python-gitlab %s+ is needed for gitlab_project_access_token" % ",".join(map(str, PYTHON_GITLAB_MINIMAL_VERSION))) + + self.moduleUtil = GitLabProjectAccessToken(module=self.mock_module, gitlab_instance=self.gitlab_instance) + + @with_httmock(resp_get_project) + @with_httmock(resp_list_project_access_tokens) + def test_find_access_token(self): + project = self.gitlab_instance.projects.get(1) + self.assertIsNotNone(project) + + rvalue = self.moduleUtil.find_access_token(project, "token1") + self.assertEqual(rvalue, False) + self.assertIsNotNone(self.moduleUtil.access_token_object) + + @with_httmock(resp_get_project) + @with_httmock(resp_list_project_access_tokens) + def test_find_access_token_negative(self): + project = self.gitlab_instance.projects.get(1) + self.assertIsNotNone(project) + + rvalue = self.moduleUtil.find_access_token(project, "nonexisting") + self.assertEqual(rvalue, False) + self.assertIsNone(self.moduleUtil.access_token_object) + + @with_httmock(resp_get_project) + @with_httmock(resp_create_project_access_tokens) + def test_create_access_token(self): + project = self.gitlab_instance.projects.get(1) + self.assertIsNotNone(project) + + rvalue = self.moduleUtil.create_access_token(project, {'name': "tokenXYZ", 'scopes': ["api"], 'access_level': 20, 'expires_at': "2024-12-31"}) + self.assertEqual(rvalue, True) + self.assertIsNotNone(self.moduleUtil.access_token_object) + + @with_httmock(resp_get_project) + @with_httmock(resp_list_project_access_tokens) + @with_httmock(resp_revoke_project_access_tokens) + def test_revoke_access_token(self): + project = self.gitlab_instance.projects.get(1) + self.assertIsNotNone(project) + + rvalue = self.moduleUtil.find_access_token(project, "token1") + self.assertEqual(rvalue, False) + self.assertIsNotNone(self.moduleUtil.access_token_object) + + rvalue = self.moduleUtil.revoke_access_token() + self.assertEqual(rvalue, True)