gitlabform 0.0.540a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. gitlabform/__init__.py +719 -0
  2. gitlabform/configuration/__init__.py +12 -0
  3. gitlabform/configuration/common.py +19 -0
  4. gitlabform/configuration/core.py +323 -0
  5. gitlabform/configuration/groups.py +127 -0
  6. gitlabform/configuration/projects.py +73 -0
  7. gitlabform/configuration/transform.py +259 -0
  8. gitlabform/constants.py +7 -0
  9. gitlabform/gitlab/__init__.py +108 -0
  10. gitlabform/gitlab/commits.py +39 -0
  11. gitlabform/gitlab/core.py +334 -0
  12. gitlabform/gitlab/group_badges.py +50 -0
  13. gitlabform/gitlab/group_ldap_links.py +40 -0
  14. gitlabform/gitlab/groups.py +96 -0
  15. gitlabform/gitlab/merge_requests.py +57 -0
  16. gitlabform/gitlab/pipelines.py +23 -0
  17. gitlabform/gitlab/project_badges.py +52 -0
  18. gitlabform/gitlab/project_deploy_keys.py +102 -0
  19. gitlabform/gitlab/project_merge_requests_approvals.py +94 -0
  20. gitlabform/gitlab/project_protected_environments.py +37 -0
  21. gitlabform/gitlab/projects.py +151 -0
  22. gitlabform/gitlab/python_gitlab.py +251 -0
  23. gitlabform/gitlab/variables.py +47 -0
  24. gitlabform/lists/__init__.py +62 -0
  25. gitlabform/lists/filter.py +99 -0
  26. gitlabform/lists/groups.py +87 -0
  27. gitlabform/lists/projects.py +239 -0
  28. gitlabform/output.py +46 -0
  29. gitlabform/processors/__init__.py +43 -0
  30. gitlabform/processors/abstract_processor.py +187 -0
  31. gitlabform/processors/application/__init__.py +17 -0
  32. gitlabform/processors/application/application_settings_processor.py +39 -0
  33. gitlabform/processors/defining_keys.py +152 -0
  34. gitlabform/processors/group/__init__.py +48 -0
  35. gitlabform/processors/group/group_badges_processor.py +17 -0
  36. gitlabform/processors/group/group_hooks_processor.py +75 -0
  37. gitlabform/processors/group/group_labels_processor.py +28 -0
  38. gitlabform/processors/group/group_ldap_links_processor.py +16 -0
  39. gitlabform/processors/group/group_members_processor.py +287 -0
  40. gitlabform/processors/group/group_push_rules_processor.py +44 -0
  41. gitlabform/processors/group/group_saml_links_processor.py +65 -0
  42. gitlabform/processors/group/group_settings_processor.py +90 -0
  43. gitlabform/processors/group/group_variables_processor.py +26 -0
  44. gitlabform/processors/multiple_entities_processor.py +171 -0
  45. gitlabform/processors/project/__init__.py +80 -0
  46. gitlabform/processors/project/badges_processor.py +17 -0
  47. gitlabform/processors/project/branches_processor.py +514 -0
  48. gitlabform/processors/project/deploy_keys_processor.py +18 -0
  49. gitlabform/processors/project/files_processor.py +301 -0
  50. gitlabform/processors/project/hooks_processor.py +64 -0
  51. gitlabform/processors/project/integrations_processor.py +33 -0
  52. gitlabform/processors/project/job_token_scope_processor.py +216 -0
  53. gitlabform/processors/project/members_processor.py +204 -0
  54. gitlabform/processors/project/merge_requests_approval_rules.py +17 -0
  55. gitlabform/processors/project/merge_requests_approvals.py +59 -0
  56. gitlabform/processors/project/project_labels_processor.py +27 -0
  57. gitlabform/processors/project/project_processor.py +62 -0
  58. gitlabform/processors/project/project_push_rules_processor.py +52 -0
  59. gitlabform/processors/project/project_security_settings.py +66 -0
  60. gitlabform/processors/project/project_settings_processor.py +239 -0
  61. gitlabform/processors/project/project_variables_processor.py +94 -0
  62. gitlabform/processors/project/remote_mirrors_processor.py +278 -0
  63. gitlabform/processors/project/resource_groups_processor.py +48 -0
  64. gitlabform/processors/project/schedules_processor.py +208 -0
  65. gitlabform/processors/project/tags_processor.py +108 -0
  66. gitlabform/processors/shared/__init__.py +0 -0
  67. gitlabform/processors/shared/protected_environments_processor.py +20 -0
  68. gitlabform/processors/util/__init__.py +0 -0
  69. gitlabform/processors/util/decorators.py +44 -0
  70. gitlabform/processors/util/difference_logger.py +70 -0
  71. gitlabform/processors/util/labels_processor.py +120 -0
  72. gitlabform/processors/util/variables_processor.py +143 -0
  73. gitlabform/run.py +9 -0
  74. gitlabform/util.py +7 -0
  75. gitlabform-0.0.540a0.dist-info/METADATA +54 -0
  76. gitlabform-0.0.540a0.dist-info/RECORD +79 -0
  77. gitlabform-0.0.540a0.dist-info/WHEEL +4 -0
  78. gitlabform-0.0.540a0.dist-info/entry_points.txt +9 -0
  79. gitlabform-0.0.540a0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,102 @@
1
+ from gitlabform.gitlab.core import UnexpectedResponseException
2
+ from gitlabform.gitlab.projects import GitLabProjects
3
+
4
+
5
+ class GitLabProjectDeployKeys(GitLabProjects):
6
+ def get_all_deploy_keys(self):
7
+ return self._make_requests_to_api("deploy_keys")
8
+
9
+ def get_deploy_keys(self, project_and_group_name):
10
+ return self._make_requests_to_api("projects/%s/deploy_keys", project_and_group_name)
11
+
12
+ def post_deploy_key(self, project_and_group_name, deploy_key_in_config):
13
+ # deploy_key_in_config has to be like this:
14
+ # {
15
+ # 'title': title,
16
+ # 'key': key,
17
+ # 'can_push': can_push,
18
+ # }
19
+ # ..as documented at: https://docs.gitlab.com/ce/api/deploy_keys.html#add-deploy-key
20
+
21
+ try:
22
+ self._make_requests_to_api(
23
+ "projects/%s/deploy_keys",
24
+ project_and_group_name,
25
+ "POST",
26
+ deploy_key_in_config,
27
+ expected_codes=[201],
28
+ )
29
+
30
+ except UnexpectedResponseException as e:
31
+ if e.response_status_code == 400 and ("has already been taken" in e.response_text):
32
+ # Sometimes GitLab throws HTTP 400: {"deploy_key.fingerprint_sha256":["has already been taken"]}
33
+ # when you try to add an existing SSH key to another project, although according to the API docs
34
+ # it should work
35
+ # (see https://docs.gitlab.com/ee/api/deploy_keys.html#add-deploy-keys-to-multiple-projects).
36
+
37
+ # As a workaround, when that happens, we will try to get this key's id and enable this existing key
38
+ # for the new project.
39
+
40
+ all_existing_keys = self.get_all_deploy_keys()
41
+
42
+ existing_key_id = None
43
+ for existing_key in all_existing_keys:
44
+ if self._keys_are_effectively_equal(existing_key["key"], deploy_key_in_config["key"]):
45
+ existing_key_id = existing_key["id"]
46
+ break
47
+
48
+ if existing_key_id:
49
+ # POST /projects/:id/deploy_keys/:key_id/enable
50
+ self._make_requests_to_api(
51
+ "projects/%s/deploy_keys/%s/enable",
52
+ (project_and_group_name, existing_key_id),
53
+ "POST",
54
+ expected_codes=201,
55
+ )
56
+ else:
57
+ e.message = (
58
+ e.message
59
+ + "\n"
60
+ + "!!! However we haven't found this key in GitLab under the /deploy_keys API... !!!"
61
+ )
62
+ raise e
63
+
64
+ def put_deploy_key(self, project_and_group_name, deploy_key_in_gitlab, deploy_key_in_config):
65
+ # according to docs_new at https://docs.gitlab.com/ee/api/deploy_keys.html#update-deploy-key
66
+ # you only can change key's title and can_push param
67
+
68
+ changeable_data = {
69
+ "title": deploy_key_in_config.get("title", None),
70
+ "can_push": deploy_key_in_config.get("can_push", None),
71
+ }
72
+
73
+ return self._make_requests_to_api(
74
+ "projects/%s/deploy_keys/%s",
75
+ (project_and_group_name, deploy_key_in_gitlab["id"]),
76
+ "PUT",
77
+ changeable_data,
78
+ )
79
+
80
+ def delete_deploy_key(self, project_and_group_name, deploy_key_in_config):
81
+ return self._make_requests_to_api(
82
+ "projects/%s/deploy_keys/%s",
83
+ (project_and_group_name, deploy_key_in_config["id"]),
84
+ method="DELETE",
85
+ expected_codes=[204, 404],
86
+ )
87
+
88
+ def get_deploy_key(self, project_and_group_name, id):
89
+ return self._make_requests_to_api("projects/%s/deploy_keys/%s", (project_and_group_name, id), "GET")
90
+
91
+ @staticmethod
92
+ def _keys_are_effectively_equal(key1, key2):
93
+ # We ignore the comment part of the SSH key as GitLab doesn't allow adding the same key just
94
+ # with a different comment BUT it also has a bug that it returns keys with only parts of the
95
+ # comments if the comment contains spaces, so it may show a difference where there is none...
96
+
97
+ key1_type = key1.split(" ")[0]
98
+ key1_value = key1.split(" ")[1]
99
+ key2_type = key2.split(" ")[0]
100
+ key2_value = key2.split(" ")[1]
101
+
102
+ return (key1_type == key2_type) and (key1_value == key2_value)
@@ -0,0 +1,94 @@
1
+ from gitlabform.gitlab.core import (
2
+ NotFoundException,
3
+ GitLabCore,
4
+ )
5
+
6
+
7
+ class GitLabProjectMergeRequestsApprovals(GitLabCore):
8
+ # approval rules
9
+
10
+ def get_approval_rules(self, project_and_group_name):
11
+ # for this endpoint GitLab still actually wants pid, not "group/project"...
12
+ pid = self._get_project_id(project_and_group_name)
13
+ return self._make_requests_to_api("projects/%s/approval_rules", pid)
14
+
15
+ def get_approval_rule(self, project_and_group_name, name):
16
+ # for this endpoint GitLab still actually wants pid, not "group/project"...
17
+ pid = self._get_project_id(project_and_group_name)
18
+ rules = self._make_requests_to_api("projects/%s/approval_rules", pid)
19
+ for rule in rules:
20
+ if rule["name"] == name:
21
+ return rule
22
+ raise NotFoundException
23
+
24
+ def add_approval_rule(
25
+ self,
26
+ project_and_group_name,
27
+ data,
28
+ ):
29
+ pid = self._get_project_id(project_and_group_name)
30
+
31
+ if "protected_branches" in data:
32
+ self._transform_protected_branches(data, project_and_group_name)
33
+
34
+ self._make_requests_to_api(
35
+ "projects/%s/approval_rules",
36
+ pid,
37
+ method="POST",
38
+ data=None,
39
+ expected_codes=201,
40
+ json=data,
41
+ )
42
+
43
+ def edit_approval_rule(
44
+ self,
45
+ project_and_group_name,
46
+ rule_in_gitlab,
47
+ rule_in_config,
48
+ ):
49
+ pid = self._get_project_id(project_and_group_name)
50
+ approval_rule_id = rule_in_gitlab["id"]
51
+
52
+ # GitLab interprets not passing any of these lists as "do not change them"
53
+ # while what we really what is in this case is "clear them"
54
+ if "user_ids" not in rule_in_config:
55
+ rule_in_config["user_ids"] = []
56
+ if "group_ids" not in rule_in_config:
57
+ rule_in_config["group_ids"] = []
58
+ if "protected_branches" in rule_in_config:
59
+ self._transform_protected_branches(rule_in_config, project_and_group_name)
60
+ else:
61
+ rule_in_config["protected_branch_ids"] = []
62
+
63
+ self._make_requests_to_api(
64
+ "projects/%s/approval_rules/%s",
65
+ (pid, approval_rule_id),
66
+ method="PUT",
67
+ data=None,
68
+ json=rule_in_config,
69
+ )
70
+
71
+ def delete_approval_rule(self, project_and_group_name, rule_in_gitlab):
72
+ # for this endpoint GitLab still actually wants pid, not "group/project"...
73
+ pid = self._get_project_id(project_and_group_name)
74
+ approval_rule_id = rule_in_gitlab["id"]
75
+
76
+ self._make_requests_to_api(
77
+ "projects/%s/approval_rules/%s",
78
+ (pid, approval_rule_id),
79
+ method="DELETE",
80
+ expected_codes=[200, 204],
81
+ )
82
+
83
+ def _transform_protected_branches(self, data, project_and_group_name):
84
+ # we do this transformation here instead of in a ConfigurationTransformers
85
+ # because we need a context of the project and group to convert branch names to ids
86
+ # and that is not available there
87
+
88
+ protected_branches_name = data["protected_branches"]
89
+ data.pop("protected_branches")
90
+ protected_branch_ids = []
91
+ for branch_name in protected_branches_name:
92
+ branch_id = self._get_protected_branch_id(project_and_group_name, branch_name)
93
+ protected_branch_ids.append(branch_id)
94
+ data["protected_branch_ids"] = protected_branch_ids
@@ -0,0 +1,37 @@
1
+ from logging import info
2
+
3
+ from gitlabform.gitlab.projects import GitLabProjects
4
+
5
+
6
+ class GitLabProjectProtectedEnvironments(GitLabProjects):
7
+ def list_protected_environments(self, project_and_group_name: str):
8
+ return self._make_requests_to_api("projects/%s/protected_environments", project_and_group_name)
9
+
10
+ def protect_a_repository_environment(
11
+ self, project_and_group_name: str, protected_env_cfg: dict, retry: bool = True
12
+ ):
13
+ response = self._make_requests_to_api(
14
+ "projects/%s/protected_environments",
15
+ project_and_group_name,
16
+ method="POST",
17
+ json=protected_env_cfg,
18
+ expected_codes=201,
19
+ )
20
+
21
+ # TODO: remove this when this issue is resolved -> https://gitlab.com/gitlab-org/gitlab/-/issues/378657
22
+ if retry and (len(protected_env_cfg["deploy_access_levels"]) != len(response["deploy_access_levels"])):
23
+ info(f'Gitlab\'s returned "deploy_access_levels" differs from the sent cfg, trying again...')
24
+
25
+ self.unprotect_environment(project_and_group_name, protected_env_cfg)
26
+
27
+ return self.protect_a_repository_environment(project_and_group_name, protected_env_cfg, False)
28
+
29
+ return response
30
+
31
+ def unprotect_environment(self, project_and_group_name: str, protected_env_cfg: dict):
32
+ return self._make_requests_to_api(
33
+ "projects/%s/protected_environments/%s",
34
+ (project_and_group_name, protected_env_cfg["name"]),
35
+ method="DELETE",
36
+ expected_codes=204,
37
+ )
@@ -0,0 +1,151 @@
1
+ from time import sleep
2
+
3
+ from gitlabform.gitlab.core import (
4
+ GitLabCore,
5
+ NotFoundException,
6
+ TimeoutWaitingForDeletion,
7
+ )
8
+
9
+
10
+ class GitLabProjects(GitLabCore):
11
+ def get_project_case_insensitive(self, some_string):
12
+ # maybe "foo/bar" is some project's path
13
+
14
+ try:
15
+ # try with exact case
16
+ return self.get_project(some_string)
17
+ except NotFoundException:
18
+ # try case insensitive
19
+ projects = self._make_requests_to_api(
20
+ f"projects?search=%s&simple=true",
21
+ some_string.lower(),
22
+ method="GET",
23
+ )
24
+ for project in projects:
25
+ if project["path_with_namespace"].lower() == some_string.lower():
26
+ return project
27
+ raise NotFoundException(f"Project with path '{some_string}' not found.")
28
+
29
+ def create_project(
30
+ self,
31
+ name,
32
+ path,
33
+ namespace_id,
34
+ default_branch=None,
35
+ wait_if_still_being_deleted=False,
36
+ ):
37
+ data = {
38
+ "name": name,
39
+ "path": path,
40
+ "namespace_id": namespace_id,
41
+ }
42
+ if default_branch:
43
+ data["default_branch"] = default_branch
44
+
45
+ if wait_if_still_being_deleted:
46
+ # GitLab deletes the project asynchronously, it may take a few seconds.
47
+ # So if you are creating new project with the same name as the one
48
+ # that is still being deleted, GitLab returns code 400
49
+ # and "The project is still being deleted.". Let's retry a few times
50
+ # then to start creating when the deletion is done.
51
+ # (Note: this code DOES NOT support the "Delayed Project deletion" feature
52
+ # where the actual deletion can be postponed for days!)
53
+
54
+ max_retries = 10
55
+ wait_before_retry = 3
56
+ retry = 0
57
+
58
+ while True:
59
+ retry += 1
60
+
61
+ if retry > max_retries:
62
+ raise TimeoutWaitingForDeletion
63
+
64
+ response = self._make_requests_to_api("projects", data=data, method="POST", expected_codes=[201, 400])
65
+ if self._is_project_still_deleted(response):
66
+ # wait & retry
67
+ sleep(wait_before_retry)
68
+ continue
69
+ else:
70
+ return response
71
+
72
+ else:
73
+ return self._make_requests_to_api("projects", data=data, method="POST", expected_codes=201)
74
+
75
+ @staticmethod
76
+ def _is_project_still_deleted(response):
77
+ # check if response looks like this:
78
+ # {'message': {'base': ['The project is still being deleted. Please try again later.'],
79
+ # 'limit_reached': []}}
80
+ return (
81
+ "message" in response
82
+ and "base" in response["message"]
83
+ and type(response["message"]["base"]) == list
84
+ and len(response["message"]["base"]) == 1
85
+ and "The project is still being deleted." in response["message"]["base"][0]
86
+ )
87
+
88
+ def delete_project(self, project_and_group_name):
89
+ # 404 means that the project does not exist anymore, so let's accept it for idempotency
90
+ return self._make_requests_to_api(
91
+ "projects/%s",
92
+ project_and_group_name,
93
+ method="DELETE",
94
+ expected_codes=[202, 204, 404],
95
+ )
96
+
97
+ # GitLab deletes the project asynchronously, it may take a few seconds
98
+ # BUT it doesn't return such not yet deleted project in GET calls, so
99
+ # there is no point in checking if it actually done here. :(
100
+ # See create_project() for the code that deals with that.
101
+
102
+ def get_all_projects(self, include_archived=False):
103
+ """
104
+ :param include_archived: if the archived projects should be returned too
105
+ :return: sorted list of ALL projects you have access to, strings like: "group/project_name"
106
+ """
107
+ try:
108
+ # there are 3 states of the "archived" flag: true, false, undefined
109
+ # we use the last 2
110
+ if include_archived:
111
+ query_string = "order_by=name&sort=asc"
112
+ else:
113
+ query_string = "order_by=name&sort=asc&archived=false"
114
+ result = self._make_requests_to_api(f"projects?{query_string}")
115
+ return sorted(map(lambda x: x["path_with_namespace"], result))
116
+ except NotFoundException:
117
+ return []
118
+
119
+ def get_groups_from_project(self, project_and_group_name):
120
+ # couldn't find an API call that was giving me directly
121
+ # the shared groups, so I'm using directly the GET /projects/:id call
122
+ project_info = self._make_requests_to_api("projects/%s", project_and_group_name)
123
+
124
+ # it will return {group_name: {...api info about group_name...}, ...}
125
+ groups = {}
126
+ for group in project_info["shared_with_groups"]:
127
+ groups[group["group_full_path"]] = group
128
+
129
+ return groups
130
+
131
+ def share_with_group(self, project_and_group_name, group_name, group_access, expires_at):
132
+ data = {"group_id": self._get_group_id(group_name), "expires_at": expires_at}
133
+ if group_access is not None:
134
+ data["group_access"] = group_access
135
+ return self._make_requests_to_api(
136
+ "projects/%s/share",
137
+ project_and_group_name,
138
+ method="POST",
139
+ data=data,
140
+ expected_codes=201,
141
+ )
142
+
143
+ def unshare_with_group(self, project_and_group_name, group_name):
144
+ # 404 means that the group already has not access, so let's accept it for idempotency
145
+ group_id = self._get_group_id(group_name)
146
+ return self._make_requests_to_api(
147
+ "projects/%s/share/%s",
148
+ (project_and_group_name, group_id),
149
+ method="DELETE",
150
+ expected_codes=[204, 404],
151
+ )
@@ -0,0 +1,251 @@
1
+ import functools
2
+ from typing import Union, Any, Optional, Dict, List
3
+
4
+ import gitlab.const
5
+ from gitlab import Gitlab, GitlabGetError, GraphQL
6
+ from gitlab.base import RESTObject
7
+ from gitlab.v4.objects import Group, Project, User
8
+
9
+ from logging import info
10
+
11
+
12
+ # Extends the python-gitlab class to add convenience wrappers for common functionality used within gitlabform
13
+ class PythonGitlab(Gitlab):
14
+ def __init__(
15
+ self,
16
+ graphql: GraphQL,
17
+ url: Optional[str] = None,
18
+ private_token: Optional[str] = None,
19
+ oauth_token: Optional[str] = None,
20
+ job_token: Optional[str] = None,
21
+ ssl_verify: Union[bool, str] = True,
22
+ http_username: Optional[str] = None,
23
+ http_password: Optional[str] = None,
24
+ timeout: Optional[float] = None,
25
+ api_version: str = "4",
26
+ per_page: Optional[int] = None,
27
+ pagination: Optional[str] = None,
28
+ order_by: Optional[str] = None,
29
+ user_agent: str = gitlab.const.USER_AGENT,
30
+ retry_transient_errors: bool = False,
31
+ keep_base_url: bool = False,
32
+ **kwargs: Any,
33
+ ) -> None:
34
+ super().__init__(
35
+ url,
36
+ private_token,
37
+ oauth_token,
38
+ job_token,
39
+ ssl_verify,
40
+ http_username,
41
+ http_password,
42
+ timeout,
43
+ api_version,
44
+ per_page,
45
+ pagination,
46
+ order_by,
47
+ user_agent,
48
+ retry_transient_errors,
49
+ keep_base_url,
50
+ **kwargs,
51
+ )
52
+ # Python Gitlab GraphQL interface
53
+ # https://python-gitlab.readthedocs.io/en/stable/api-usage-graphql.html
54
+ self.graphql = graphql
55
+
56
+ def get_user_id_cached(self, username) -> int | None:
57
+ """
58
+ Get user id from username
59
+ API call layer "get_user_by_username" is cached, we do not cache at this level as it would add
60
+ duplication of data into the cache
61
+ """
62
+ user = self.get_user_by_username_cached(username)
63
+
64
+ if user is None:
65
+ return None
66
+
67
+ return user.id
68
+
69
+ def get_group_id(self, groupname) -> int:
70
+ group = self.get_group_by_path_cached(groupname)
71
+ return group.id
72
+
73
+ def get_project_id(self, name) -> int:
74
+ project = self.get_project_by_path_cached(name)
75
+ return project.id
76
+
77
+ @functools.lru_cache()
78
+ def get_project_by_path_cached(self, name: str, lazy: bool = False) -> Project:
79
+ project: Project = self.projects.get(name, lazy)
80
+ if project:
81
+ return project
82
+
83
+ raise GitlabGetError("No project found when getting '%s'" % name, 404)
84
+
85
+ @functools.lru_cache()
86
+ def get_group_by_path_cached(self, groupname: str) -> Group:
87
+ group: Group = self.groups.get(groupname)
88
+ if group:
89
+ return group
90
+
91
+ raise GitlabGetError("No group found when getting '%s'" % groupname, 404)
92
+
93
+ # Uses "LIST" to get a user by username, to get the full User object, call get using the user's id
94
+ @functools.lru_cache()
95
+ def get_user_by_username_cached(self, username: str) -> User | None:
96
+ # Gitlab API will only ever return 0 or 1 entry when GETting using `username` attribute
97
+ # https://docs.gitlab.com/ee/api/users.html#for-non-administrator-users
98
+ # so will always be list[RESTObject] and never RESTObjectList from python-gitlab's api
99
+ # username list is case-insensitive on the gitlab api
100
+ users: list[RESTObject] = self.users.list(username=username) # type: ignore
101
+
102
+ if len(users) == 0:
103
+ return None
104
+
105
+ user = users[0]
106
+ return self.users.get(user.id)
107
+
108
+ @functools.lru_cache()
109
+ def _get_member_roles_from_group_cached(self, group_full_path: str) -> List[Dict[str, str]]:
110
+ """Query GraphQL using Python Gitlab
111
+ https://python-gitlab.readthedocs.io/en/stable/api-usage-graphql.html
112
+
113
+ GET Member_Role via REST Api needs "Owner" on Gitlab.com or "Admin" on Dedicated
114
+ List of custom roles can be retrieved via GraphQL endpoint with "Guest+"
115
+ https://gitlab.com/gitlab-org/gitlab/-/issues/511919#note_2287581884
116
+
117
+ https://docs.gitlab.com/ee/api/graphql/reference/index.html#groupmemberroles
118
+ """
119
+
120
+ if group_full_path is None:
121
+ raise GitlabGetError(
122
+ "Group Path must be provided when getting member roles",
123
+ 404,
124
+ )
125
+
126
+ query = (
127
+ """
128
+ {
129
+ group(fullPath: \""""
130
+ + group_full_path
131
+ + """\") {
132
+ memberRoles {
133
+ nodes {
134
+ id
135
+ name
136
+ }
137
+ }
138
+ }
139
+ }
140
+ """
141
+ )
142
+ info(f"Executing graphQl query to get Member Roles for Group '{group_full_path}'")
143
+ result = self.graphql.execute(query)
144
+
145
+ # Validate Group / MemberRoles exist
146
+ if (
147
+ result["group"] is not None
148
+ and result["group"]["memberRoles"] is not None
149
+ and result["group"]["memberRoles"]["nodes"] is not None
150
+ ):
151
+ member_role_nodes = result["group"]["memberRoles"]["nodes"]
152
+ else:
153
+ raise GitlabGetError(f"Failed to get Member Roles from Group: {query}")
154
+
155
+ return self._convert_result_to_member_roles(member_role_nodes)
156
+
157
+ @functools.lru_cache()
158
+ def _get_member_roles_from_instance_cached(self) -> List[Dict[str, str]]:
159
+ """Query GraphQL using Python Gitlab
160
+ https://python-gitlab.readthedocs.io/en/stable/api-usage-graphql.html
161
+
162
+ GET Member_Role via REST Api needs "Admin" on Dedicated/Self-Hosted
163
+
164
+ On Self-Hosted/Dedicated it is only possible to create Member Roles at an instance level, so we query at that
165
+ level and cache the result in order to save API calls when processing multiple Groups
166
+
167
+ List of custom roles can be retrieved via GraphQL endpoint with "Guest+"
168
+ https://gitlab.com/gitlab-org/gitlab/-/issues/511919#note_2287581884
169
+
170
+ https://docs.gitlab.com/ee/api/graphql/reference/index.html#groupmemberroles
171
+ """
172
+
173
+ query = """
174
+ {
175
+ memberRoles {
176
+ edges {
177
+ node {
178
+ id
179
+ name
180
+ }
181
+ }
182
+ }
183
+ }
184
+ """
185
+ result = self.graphql.execute(query)
186
+
187
+ if result["memberRoles"] is not None and result["memberRoles"]["edges"] is not None:
188
+ member_role_edges = result["memberRoles"]["edges"]
189
+ else:
190
+ raise GitlabGetError(f"Failed to get Member Roles from instance: {query}")
191
+
192
+ member_role_nodes = []
193
+
194
+ for edge in member_role_edges:
195
+ member_role_nodes.append(edge["node"])
196
+
197
+ return self._convert_result_to_member_roles(member_role_nodes)
198
+
199
+ @staticmethod
200
+ def _convert_result_to_member_roles(member_role_nodes: List[Dict[str, str]]) -> List[Dict[str, str]]:
201
+ # Unwrap Ids from GraphQl Gids
202
+ member_role_id_prefix = "gid://gitlab/MemberRole/"
203
+
204
+ member_roles = []
205
+ for node in member_role_nodes:
206
+ member_role_id = node["id"]
207
+ member_role_id = member_role_id.replace(member_role_id_prefix, "")
208
+ member_roles.append({"id": member_role_id, "name": node["name"]})
209
+
210
+ return member_roles
211
+
212
+ @staticmethod
213
+ def _get_member_role_from_member_roles(
214
+ name_or_id: Union[int, str], member_roles: List[Dict[str, str]]
215
+ ) -> Dict[str, str]:
216
+ for member_role in member_roles:
217
+ member_role_id: str = member_role["id"]
218
+ member_role_name: str = member_role["name"]
219
+ if int(member_role_id) == name_or_id:
220
+ return member_role
221
+ elif member_role_name.lower() == str(name_or_id).lower():
222
+ return member_role
223
+
224
+ # Failed to find member role so throw an exception explaining such to user
225
+ raise GitlabGetError(
226
+ f"Member Role with name or id {name_or_id} could not be found",
227
+ 404,
228
+ )
229
+
230
+ def get_member_role_id_cached(self, name_or_id: Union[int, str], group_full_path: str) -> int:
231
+ """
232
+ GETs a member role id set in the config by the Name of the Member Role (by calling out to API) or by Id
233
+
234
+ Get member_roles calls themselves are Cached, the logic in this method otherwise performs no API calls,
235
+ so we should not cache the result of this specific method, otherwise we may fill the cache with duplicate data
236
+ """
237
+ if type(name_or_id) is int:
238
+ # Already supplied as an id so no need to go get it from API
239
+ return name_or_id
240
+
241
+ if self._is_gitlab_saas():
242
+ member_roles = self._get_member_roles_from_group_cached(group_full_path)
243
+ else:
244
+ member_roles = self._get_member_roles_from_instance_cached()
245
+
246
+ member_role = self._get_member_role_from_member_roles(name_or_id, member_roles)
247
+
248
+ return int(member_role["id"])
249
+
250
+ def _is_gitlab_saas(self) -> bool:
251
+ return self.url == gitlab.const.DEFAULT_URL
@@ -0,0 +1,47 @@
1
+ from gitlabform.gitlab.projects import GitLabProjects
2
+
3
+
4
+ class GitLabVariables(GitLabProjects):
5
+ def get_variables(self, project_and_group_name):
6
+ return self._make_requests_to_api("projects/%s/variables", project_and_group_name)
7
+
8
+ def post_variable(self, project_and_group_name, variable_in_config):
9
+ # variable has to be like documented at:
10
+ # https://docs.gitlab.com/ee/api/project_level_variables.html#create-variable
11
+ self._make_requests_to_api(
12
+ "projects/%s/variables",
13
+ project_and_group_name,
14
+ "POST",
15
+ variable_in_config,
16
+ expected_codes=201,
17
+ )
18
+
19
+ def put_variable(
20
+ self,
21
+ project_and_group_name,
22
+ variable_in_gitlab,
23
+ variable_in_config,
24
+ ):
25
+ # variable has to be like documented at:
26
+ # https://docs.gitlab.com/ce/api/build_variables.html#update-variable
27
+ self._make_requests_to_api(
28
+ "projects/%s/variables/%s",
29
+ (project_and_group_name, variable_in_gitlab["key"]),
30
+ "PUT",
31
+ variable_in_config,
32
+ )
33
+
34
+ def delete_variable(self, project_and_group_name, variable_in_config):
35
+ self._make_requests_to_api(
36
+ "projects/%s/variables/%s",
37
+ (project_and_group_name, variable_in_config["key"]),
38
+ method="DELETE",
39
+ expected_codes=[204, 404],
40
+ )
41
+
42
+ def get_variable(self, project_and_group_name, variable_key, environment_scope="*"):
43
+ if environment_scope == "*":
44
+ url = "projects/%s/variables/%s"
45
+ else:
46
+ url = f"projects/%s/variables/%s?filter[environment_scope]={environment_scope}"
47
+ return self._make_requests_to_api(url, (project_and_group_name, variable_key))["value"]