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.
- gitlabform/__init__.py +719 -0
- gitlabform/configuration/__init__.py +12 -0
- gitlabform/configuration/common.py +19 -0
- gitlabform/configuration/core.py +323 -0
- gitlabform/configuration/groups.py +127 -0
- gitlabform/configuration/projects.py +73 -0
- gitlabform/configuration/transform.py +259 -0
- gitlabform/constants.py +7 -0
- gitlabform/gitlab/__init__.py +108 -0
- gitlabform/gitlab/commits.py +39 -0
- gitlabform/gitlab/core.py +334 -0
- gitlabform/gitlab/group_badges.py +50 -0
- gitlabform/gitlab/group_ldap_links.py +40 -0
- gitlabform/gitlab/groups.py +96 -0
- gitlabform/gitlab/merge_requests.py +57 -0
- gitlabform/gitlab/pipelines.py +23 -0
- gitlabform/gitlab/project_badges.py +52 -0
- gitlabform/gitlab/project_deploy_keys.py +102 -0
- gitlabform/gitlab/project_merge_requests_approvals.py +94 -0
- gitlabform/gitlab/project_protected_environments.py +37 -0
- gitlabform/gitlab/projects.py +151 -0
- gitlabform/gitlab/python_gitlab.py +251 -0
- gitlabform/gitlab/variables.py +47 -0
- gitlabform/lists/__init__.py +62 -0
- gitlabform/lists/filter.py +99 -0
- gitlabform/lists/groups.py +87 -0
- gitlabform/lists/projects.py +239 -0
- gitlabform/output.py +46 -0
- gitlabform/processors/__init__.py +43 -0
- gitlabform/processors/abstract_processor.py +187 -0
- gitlabform/processors/application/__init__.py +17 -0
- gitlabform/processors/application/application_settings_processor.py +39 -0
- gitlabform/processors/defining_keys.py +152 -0
- gitlabform/processors/group/__init__.py +48 -0
- gitlabform/processors/group/group_badges_processor.py +17 -0
- gitlabform/processors/group/group_hooks_processor.py +75 -0
- gitlabform/processors/group/group_labels_processor.py +28 -0
- gitlabform/processors/group/group_ldap_links_processor.py +16 -0
- gitlabform/processors/group/group_members_processor.py +287 -0
- gitlabform/processors/group/group_push_rules_processor.py +44 -0
- gitlabform/processors/group/group_saml_links_processor.py +65 -0
- gitlabform/processors/group/group_settings_processor.py +90 -0
- gitlabform/processors/group/group_variables_processor.py +26 -0
- gitlabform/processors/multiple_entities_processor.py +171 -0
- gitlabform/processors/project/__init__.py +80 -0
- gitlabform/processors/project/badges_processor.py +17 -0
- gitlabform/processors/project/branches_processor.py +514 -0
- gitlabform/processors/project/deploy_keys_processor.py +18 -0
- gitlabform/processors/project/files_processor.py +301 -0
- gitlabform/processors/project/hooks_processor.py +64 -0
- gitlabform/processors/project/integrations_processor.py +33 -0
- gitlabform/processors/project/job_token_scope_processor.py +216 -0
- gitlabform/processors/project/members_processor.py +204 -0
- gitlabform/processors/project/merge_requests_approval_rules.py +17 -0
- gitlabform/processors/project/merge_requests_approvals.py +59 -0
- gitlabform/processors/project/project_labels_processor.py +27 -0
- gitlabform/processors/project/project_processor.py +62 -0
- gitlabform/processors/project/project_push_rules_processor.py +52 -0
- gitlabform/processors/project/project_security_settings.py +66 -0
- gitlabform/processors/project/project_settings_processor.py +239 -0
- gitlabform/processors/project/project_variables_processor.py +94 -0
- gitlabform/processors/project/remote_mirrors_processor.py +278 -0
- gitlabform/processors/project/resource_groups_processor.py +48 -0
- gitlabform/processors/project/schedules_processor.py +208 -0
- gitlabform/processors/project/tags_processor.py +108 -0
- gitlabform/processors/shared/__init__.py +0 -0
- gitlabform/processors/shared/protected_environments_processor.py +20 -0
- gitlabform/processors/util/__init__.py +0 -0
- gitlabform/processors/util/decorators.py +44 -0
- gitlabform/processors/util/difference_logger.py +70 -0
- gitlabform/processors/util/labels_processor.py +120 -0
- gitlabform/processors/util/variables_processor.py +143 -0
- gitlabform/run.py +9 -0
- gitlabform/util.py +7 -0
- gitlabform-0.0.540a0.dist-info/METADATA +54 -0
- gitlabform-0.0.540a0.dist-info/RECORD +79 -0
- gitlabform-0.0.540a0.dist-info/WHEEL +4 -0
- gitlabform-0.0.540a0.dist-info/entry_points.txt +9 -0
- 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"]
|