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,514 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Optional, Any
|
|
3
|
+
|
|
4
|
+
from logging import info, warning, error, critical
|
|
5
|
+
from gitlab import (
|
|
6
|
+
GitlabGetError,
|
|
7
|
+
GitlabDeleteError,
|
|
8
|
+
GitlabOperationError,
|
|
9
|
+
)
|
|
10
|
+
from gitlab.v4.objects import Project, ProjectProtectedBranch
|
|
11
|
+
|
|
12
|
+
from gitlabform.constants import EXIT_INVALID_INPUT, EXIT_PROCESSING_ERROR
|
|
13
|
+
from gitlabform.gitlab import GitLab
|
|
14
|
+
from gitlabform.processors.abstract_processor import AbstractProcessor
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BranchesProcessor(AbstractProcessor):
|
|
18
|
+
"""
|
|
19
|
+
Processor for branch protection settings.
|
|
20
|
+
|
|
21
|
+
This processor is complex because GitLab's Protected Branches API uses different
|
|
22
|
+
data structures for Create (POST), Get (GET), and Update (PATCH) operations.
|
|
23
|
+
|
|
24
|
+
It implements 'Additive Design' (existing rules are preserved) and
|
|
25
|
+
'Raw Parameter Passing' (arbitrary keys in config are sent to the API).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, gitlab: GitLab, strict: bool):
|
|
29
|
+
super().__init__("branches", gitlab)
|
|
30
|
+
self.strict = strict
|
|
31
|
+
|
|
32
|
+
# Protected Branch API: https://docs.gitlab.com/api/protected_branches/#update-a-protected-branch
|
|
33
|
+
# Behind the scenes gitlab will map "allowed_to_merge" and "merge_access_level" to "merge_access_levels"
|
|
34
|
+
# and the same for unprotect and push access, so we need some custom diff analyzers to validate if the config
|
|
35
|
+
# has actually changed
|
|
36
|
+
self.custom_diff_analyzers["merge_access_levels"] = self.naive_access_level_diff_analyzer
|
|
37
|
+
self.custom_diff_analyzers["push_access_levels"] = self.naive_access_level_diff_analyzer
|
|
38
|
+
self.custom_diff_analyzers["unprotect_access_levels"] = self.naive_access_level_diff_analyzer
|
|
39
|
+
|
|
40
|
+
def _can_proceed(self, project_or_group: str, configuration: dict):
|
|
41
|
+
for branch in sorted(configuration["branches"]):
|
|
42
|
+
branch_config = configuration["branches"][branch]
|
|
43
|
+
if branch_config.get("protected") is None:
|
|
44
|
+
critical(f"The Protected key is mandatory in branches configuration, fix {branch} YAML config")
|
|
45
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
46
|
+
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def _process_configuration(self, project_and_group: str, configuration: dict):
|
|
50
|
+
"""
|
|
51
|
+
Called from process defined in abstract_processor.py after checking self._can_proceed
|
|
52
|
+
Iterates through all branches defined in the configuration and applies protection rules.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
project: Project = self.gl.get_project_by_path_cached(project_and_group)
|
|
56
|
+
|
|
57
|
+
for branch in sorted(configuration["branches"]):
|
|
58
|
+
branch_configuration: dict = self.convert_user_and_group_names_to_ids(configuration["branches"][branch])
|
|
59
|
+
|
|
60
|
+
self.process_branch_protection(project, branch, branch_configuration)
|
|
61
|
+
|
|
62
|
+
def process_branch_protection(self, project: Project, branch_name: str, branch_config: dict):
|
|
63
|
+
"""
|
|
64
|
+
High-level logic for processing branch protection.
|
|
65
|
+
|
|
66
|
+
1. Validates branch existence (unless wildcard).
|
|
67
|
+
2. Handles 'protected: false' (unprotecting).
|
|
68
|
+
3. Handles 'protected: true':
|
|
69
|
+
- If not currently protected: Create protection.
|
|
70
|
+
- If protected: Update using PATCH (EE > 15.6) or Unprotect/Reprotect (CE/Old EE).
|
|
71
|
+
"""
|
|
72
|
+
protected_branch: Optional[ProjectProtectedBranch] = None
|
|
73
|
+
|
|
74
|
+
# If protected branch name contains a supported wildcard do not try looking it up
|
|
75
|
+
if not self.branch_name_contains_supported_wildcard(branch_name):
|
|
76
|
+
# Check branch we are trying to protect actually exists first
|
|
77
|
+
try:
|
|
78
|
+
project.branches.get(branch_name)
|
|
79
|
+
except GitlabGetError:
|
|
80
|
+
message = f"Branch '{branch_name}' not found when processing it to be protected/unprotected!"
|
|
81
|
+
if self.strict:
|
|
82
|
+
critical(message)
|
|
83
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
84
|
+
else:
|
|
85
|
+
warning(message)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
protected_branch = project.protectedbranches.get(branch_name)
|
|
89
|
+
except GitlabGetError:
|
|
90
|
+
message = f"The branch '{branch_name}' is not protected!"
|
|
91
|
+
info(message)
|
|
92
|
+
|
|
93
|
+
if branch_config.get("protected"):
|
|
94
|
+
if not protected_branch:
|
|
95
|
+
info(f"Creating branch protection for {branch_name}")
|
|
96
|
+
self.protect_branch(project, branch_name, branch_config, False)
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
# https://docs.gitlab.com/api/protected_branches/#update-a-protected-branch was only introduced after 15.6
|
|
100
|
+
# for user's on older versions of Gitlab or Community Edition (https://gitlab.com/rluna-gitlab/gitlab-ce/-/work_items/37)
|
|
101
|
+
# We need to unprotect and then reprotect the branch to apply the
|
|
102
|
+
# defined configuration
|
|
103
|
+
if self.gitlab.is_version_less_than("15.6.0") or (self.gitlab.enterprise == False):
|
|
104
|
+
self.process_branch_config_gitlab_under_15_6_0_or_ce(
|
|
105
|
+
branch_config, branch_name, project, protected_branch
|
|
106
|
+
)
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
# For later Gitlab versions we dynamically generate the data to send to the update endpoint based on the
|
|
110
|
+
# configured state and the current Gitlab state so do not need to check _needs_update first.
|
|
111
|
+
|
|
112
|
+
# Gitlab returns the allowed_to_merge etc. data in a different format from GET endpoint than it takes in
|
|
113
|
+
# the POST (create) or PATCH (update) endpoints
|
|
114
|
+
# GET: https://docs.gitlab.com/api/protected_branches/#get-a-single-protected-branch-or-wildcard-protected-branch
|
|
115
|
+
# POST: https://docs.gitlab.com/api/protected_branches/#protect-repository-branches
|
|
116
|
+
# PATCH: https://docs.gitlab.com/api/protected_branches/#update-a-protected-branch
|
|
117
|
+
# Therefore we first transform the configured YAML into a state matching the gitlab GET endpoint so we can
|
|
118
|
+
# compare states easier to determine what PATCH request we need to send (if any)
|
|
119
|
+
transformed_branch_config = self.map_config_to_protected_branch_get_data(branch_config)
|
|
120
|
+
|
|
121
|
+
protected_branch_api_patch_data: dict = {}
|
|
122
|
+
|
|
123
|
+
# RAW PARAMETER PASSING (Top-Level):
|
|
124
|
+
# Iterate through any top-level flags (e.g., allow_force_push, code_owner_approval_required)
|
|
125
|
+
# and include them in the PATCH request if they differ from the current GitLab state.
|
|
126
|
+
special_list_keys = [
|
|
127
|
+
"merge_access_levels",
|
|
128
|
+
"push_access_levels",
|
|
129
|
+
"unprotect_access_levels",
|
|
130
|
+
]
|
|
131
|
+
for key, value in transformed_branch_config.items():
|
|
132
|
+
if key not in special_list_keys:
|
|
133
|
+
# Check if this attribute exists on the GitLab object and needs an update
|
|
134
|
+
existing_value = getattr(protected_branch, key, None)
|
|
135
|
+
if existing_value != value:
|
|
136
|
+
info(f"Creating data to update {key} as necessary")
|
|
137
|
+
protected_branch_api_patch_data[key] = value
|
|
138
|
+
|
|
139
|
+
info("Creating data to update merge_access_levels as necessary")
|
|
140
|
+
merge_access_items_patch_data = self.build_patch_request_data(
|
|
141
|
+
transformed_access_levels=transformed_branch_config.get("merge_access_levels"),
|
|
142
|
+
existing_records=tuple(self._get_list_attribute(protected_branch, "merge_access_levels")),
|
|
143
|
+
)
|
|
144
|
+
if len(merge_access_items_patch_data) > 0:
|
|
145
|
+
protected_branch_api_patch_data["allowed_to_merge"] = merge_access_items_patch_data
|
|
146
|
+
|
|
147
|
+
info("Creating data to update push_access_levels as necessary")
|
|
148
|
+
push_access_items_patch_data = self.build_patch_request_data(
|
|
149
|
+
transformed_access_levels=transformed_branch_config.get("push_access_levels"),
|
|
150
|
+
existing_records=tuple(self._get_list_attribute(protected_branch, "push_access_levels")),
|
|
151
|
+
)
|
|
152
|
+
if len(push_access_items_patch_data) > 0:
|
|
153
|
+
protected_branch_api_patch_data["allowed_to_push"] = push_access_items_patch_data
|
|
154
|
+
|
|
155
|
+
info("Creating data to update unprotect_access_levels as necessary")
|
|
156
|
+
|
|
157
|
+
unprotect_access_items_patch_data = self.build_patch_request_data(
|
|
158
|
+
transformed_access_levels=transformed_branch_config.get("unprotect_access_levels"),
|
|
159
|
+
existing_records=tuple(self._get_list_attribute(protected_branch, "unprotect_access_levels")),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if len(unprotect_access_items_patch_data) > 0:
|
|
163
|
+
protected_branch_api_patch_data["allowed_to_unprotect"] = unprotect_access_items_patch_data
|
|
164
|
+
|
|
165
|
+
if protected_branch_api_patch_data != {}:
|
|
166
|
+
# We have some updates to make
|
|
167
|
+
info(f"Updating protected branch {branch_name} with {protected_branch_api_patch_data}")
|
|
168
|
+
self.protect_branch(project, branch_name, protected_branch_api_patch_data, True)
|
|
169
|
+
|
|
170
|
+
elif protected_branch and not branch_config.get("protected"):
|
|
171
|
+
info(f"Removing branch protection for {branch_name}")
|
|
172
|
+
self.unprotect_branch(protected_branch)
|
|
173
|
+
|
|
174
|
+
def process_branch_config_gitlab_under_15_6_0_or_ce(self, branch_config, branch_name, project, protected_branch):
|
|
175
|
+
"""
|
|
176
|
+
Processes the branches configuration for gitlab version <=15.6.0 or Community Edition,
|
|
177
|
+
where in-place updates (PATCH) are not supported or effective.
|
|
178
|
+
If a change is detected, the branch is unprotected and then reprotected from scratch.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
# Gitlab returns the allowed_to_merge etc data in a different format from GET endpoint than it takes in
|
|
182
|
+
# the POST (create) endpoint
|
|
183
|
+
# GET: https://docs.gitlab.com/api/protected_branches/#get-a-single-protected-branch-or-wildcard-protected-branch
|
|
184
|
+
# POST: https://docs.gitlab.com/api/protected_branches/#protect-repository-branches
|
|
185
|
+
# Therefore we first transform the configured YAML into a state matching the gitlab GET endpoint,
|
|
186
|
+
# before checking if it needs_update
|
|
187
|
+
if self._needs_update(protected_branch.attributes, self.map_config_to_protected_branch_get_data(branch_config)):
|
|
188
|
+
info(
|
|
189
|
+
f"Gitlab version is less than 15.6.0, so un-protecting and reprotecting branch {branch_name} to apply new config..."
|
|
190
|
+
)
|
|
191
|
+
self.unprotect_branch(protected_branch)
|
|
192
|
+
|
|
193
|
+
# Send the untransformed config to the POST endpoint, as GitlabForm YAML structure conforms to the POST inputs
|
|
194
|
+
self.protect_branch(project, branch_name, branch_config, False)
|
|
195
|
+
|
|
196
|
+
def protect_branch(self, project: Project, branch_name: str, branch_config: dict, update_only: bool = False):
|
|
197
|
+
"""
|
|
198
|
+
Create or update branch protection using given config.
|
|
199
|
+
Raise exception if running in strict mode.
|
|
200
|
+
|
|
201
|
+
args:
|
|
202
|
+
project (Project): Gitlab project
|
|
203
|
+
branch_name (str): Name of branch on the project to protect or update protection on
|
|
204
|
+
branch_config (dict): Branch protection configuration to apply
|
|
205
|
+
update_only (bool):
|
|
206
|
+
If True, update branch protection of branch with branch_name,
|
|
207
|
+
If False create a new protected branch with branch_name
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
if update_only:
|
|
211
|
+
project.protectedbranches.update(branch_name, branch_config)
|
|
212
|
+
else:
|
|
213
|
+
project.protectedbranches.create({"name": branch_name, **branch_config})
|
|
214
|
+
except GitlabOperationError as e:
|
|
215
|
+
message = f"Protecting branch '{branch_name}' failed! Error '{e.error_message}"
|
|
216
|
+
|
|
217
|
+
if self.strict:
|
|
218
|
+
critical(message)
|
|
219
|
+
sys.exit(EXIT_PROCESSING_ERROR)
|
|
220
|
+
else:
|
|
221
|
+
error(message)
|
|
222
|
+
|
|
223
|
+
def unprotect_branch(self, protected_branch: ProjectProtectedBranch):
|
|
224
|
+
"""
|
|
225
|
+
Unprotect a branch.
|
|
226
|
+
Raise exception if running in strict mode.
|
|
227
|
+
"""
|
|
228
|
+
try:
|
|
229
|
+
# The delete method doesn't delete the actual branch.
|
|
230
|
+
# It only unprotects the branch.
|
|
231
|
+
protected_branch.delete()
|
|
232
|
+
except GitlabDeleteError as e:
|
|
233
|
+
message = f"Branch '{protected_branch.name}' could not be unprotected! Error '{e.error_message}'"
|
|
234
|
+
if self.strict:
|
|
235
|
+
critical(message)
|
|
236
|
+
sys.exit(EXIT_PROCESSING_ERROR)
|
|
237
|
+
else:
|
|
238
|
+
warning(message)
|
|
239
|
+
|
|
240
|
+
def convert_user_and_group_names_to_ids(self, branch_config: dict):
|
|
241
|
+
"""
|
|
242
|
+
Pre-processor to resolve names to IDs.
|
|
243
|
+
Translates 'user: username' or 'group: name' into 'user_id' or 'group_id' as
|
|
244
|
+
config by replacing them with ids.
|
|
245
|
+
"""
|
|
246
|
+
info("Transforming User and Group names in Branch configuration to Ids")
|
|
247
|
+
|
|
248
|
+
for key in branch_config:
|
|
249
|
+
if isinstance(branch_config[key], list):
|
|
250
|
+
for item in branch_config[key]:
|
|
251
|
+
if isinstance(item, dict):
|
|
252
|
+
if "user" in item:
|
|
253
|
+
username = item.pop("user")
|
|
254
|
+
user_id = self.gl.get_user_id_cached(username)
|
|
255
|
+
if user_id is None:
|
|
256
|
+
raise GitlabGetError(
|
|
257
|
+
f"transform_branch_config - No users found when searching for username {username}",
|
|
258
|
+
404,
|
|
259
|
+
)
|
|
260
|
+
item["user_id"] = user_id
|
|
261
|
+
elif "group" in item:
|
|
262
|
+
item["group_id"] = self.gl.get_group_id(item.pop("group"))
|
|
263
|
+
|
|
264
|
+
return branch_config
|
|
265
|
+
|
|
266
|
+
@staticmethod
|
|
267
|
+
def map_config_to_protected_branch_get_data(our_branch_config: dict):
|
|
268
|
+
"""
|
|
269
|
+
Normalizes the user-provided YAML config into the format returned by GitLab's GET endpoint.
|
|
270
|
+
|
|
271
|
+
GitLab API Mappings:
|
|
272
|
+
- 'merge_access_level' (Standard) -> 'merge_access_levels' (List)
|
|
273
|
+
- 'allowed_to_merge' (Premium) -> 'merge_access_levels' (List)
|
|
274
|
+
|
|
275
|
+
This transformation allows for a direct comparison between the desired state and current state.
|
|
276
|
+
This method will normalize gitlabform branch_config to accommodate this.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
our_branch_config (dict): branch configuration read from .yaml file
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
dict: defined configuration transformed into the format returned by the Gitlab APIs
|
|
283
|
+
"""
|
|
284
|
+
# Also see https://github.com/python-gitlab/python-gitlab/issues/2850
|
|
285
|
+
|
|
286
|
+
info("Transforming *_access_level and allowed_to_* keys in Branch configuration")
|
|
287
|
+
local_keys_to_gitlab_keys_map = {
|
|
288
|
+
"merge_access_level": "merge_access_levels",
|
|
289
|
+
"push_access_level": "push_access_levels",
|
|
290
|
+
"unprotect_access_level": "unprotect_access_levels",
|
|
291
|
+
"allowed_to_merge": "merge_access_levels",
|
|
292
|
+
"allowed_to_push": "push_access_levels",
|
|
293
|
+
"allowed_to_unprotect": "unprotect_access_levels",
|
|
294
|
+
}
|
|
295
|
+
new_branch_config = our_branch_config.copy()
|
|
296
|
+
for key in our_branch_config:
|
|
297
|
+
if key in local_keys_to_gitlab_keys_map.keys():
|
|
298
|
+
target_key = local_keys_to_gitlab_keys_map[key]
|
|
299
|
+
# *_access_level in gitlabform will have been transformed to it's int representation already if defined
|
|
300
|
+
# by the user as "merge_access_level: Maintainer"
|
|
301
|
+
if isinstance(our_branch_config[key], int):
|
|
302
|
+
access_level = new_branch_config.pop(key)
|
|
303
|
+
new_branch_config[target_key] = [
|
|
304
|
+
{
|
|
305
|
+
"id": None,
|
|
306
|
+
"access_level": access_level,
|
|
307
|
+
"user_id": None,
|
|
308
|
+
"group_id": None,
|
|
309
|
+
"deploy_key_id": None,
|
|
310
|
+
}
|
|
311
|
+
]
|
|
312
|
+
# allowed_to_* are lists...
|
|
313
|
+
elif isinstance(our_branch_config[key], list):
|
|
314
|
+
mapped_list = []
|
|
315
|
+
for item in our_branch_config[key]:
|
|
316
|
+
# RAW PARAMETER PASSING: We ensure the core identity keys exist for comparison,
|
|
317
|
+
# but we preserve all other arbitrary keys provided by the user.
|
|
318
|
+
mapped_item = {
|
|
319
|
+
"id": None,
|
|
320
|
+
"access_level": item.get("access_level"),
|
|
321
|
+
"user_id": item.get("user_id"),
|
|
322
|
+
"group_id": item.get("group_id"),
|
|
323
|
+
"deploy_key_id": item.get("deploy_key_id"),
|
|
324
|
+
**{
|
|
325
|
+
k: v
|
|
326
|
+
for k, v in item.items()
|
|
327
|
+
if k not in ["access_level", "user_id", "group_id", "deploy_key_id", "id"]
|
|
328
|
+
},
|
|
329
|
+
}
|
|
330
|
+
mapped_list.append(mapped_item)
|
|
331
|
+
new_branch_config[target_key] = mapped_list
|
|
332
|
+
new_branch_config.pop(key)
|
|
333
|
+
|
|
334
|
+
# this key is not present in
|
|
335
|
+
# protected_branch.attributes, so _needs_update() would always
|
|
336
|
+
# return True with this key present.
|
|
337
|
+
new_branch_config.pop("protected")
|
|
338
|
+
return new_branch_config
|
|
339
|
+
|
|
340
|
+
@staticmethod
|
|
341
|
+
def build_patch_request_data(transformed_access_levels: list[dict] | None, existing_records: tuple) -> list[dict]:
|
|
342
|
+
"""
|
|
343
|
+
Calculates the specific payload for the PATCH (update) API.
|
|
344
|
+
|
|
345
|
+
Matching Logic:
|
|
346
|
+
1. For each rule in the config, check if an identical rule already exists in GitLab.
|
|
347
|
+
2. Rule Identity is determined by: user_id OR group_id OR deploy_key_id OR (role-only access_level).
|
|
348
|
+
3. If a match is found, the rule is skipped (idempotency).
|
|
349
|
+
4. If 'No Access' (0) is requested but other roles exist, the existing roles are marked for destruction.
|
|
350
|
+
|
|
351
|
+
args:
|
|
352
|
+
transformed_access_levels (list[dict|None]): transformed merge_access_levels or push_access_levels or unprotect_access_levels configration generated by transform_branch_config_access_levels
|
|
353
|
+
existing_records (tuple): immutable list of existing records for the protected branch in Gitlab
|
|
354
|
+
|
|
355
|
+
returns:
|
|
356
|
+
list[dict]: Data in the format required by the protected_branches PATCH api. https://docs.gitlab.com/api/protected_branches/#update-a-protected-branch
|
|
357
|
+
"""
|
|
358
|
+
patch_data = []
|
|
359
|
+
|
|
360
|
+
if transformed_access_levels is not None:
|
|
361
|
+
# User has defined in configuration some access level for this resource on the protected branch
|
|
362
|
+
# Prepare patch data using raw parameters from config.
|
|
363
|
+
# We only send non-None values and omit the internal 'id' placeholder used for mapping.
|
|
364
|
+
for configuration in transformed_access_levels:
|
|
365
|
+
item_to_add = {k: v for k, v in configuration.items() if v is not None and k != "id"}
|
|
366
|
+
patch_data.append(item_to_add)
|
|
367
|
+
|
|
368
|
+
# Check if we need to make the update (e.g. an existing record for that user_id or access_level exists)
|
|
369
|
+
# and mark existing records for deletion if the access_level does not match
|
|
370
|
+
# (e.g. config has changed from DEVELOPER to MAINTAINER; delete the DEVELOPER entry and add a MAINTAINER
|
|
371
|
+
# entry)
|
|
372
|
+
for existing_record in existing_records:
|
|
373
|
+
existing_records_access_level = existing_record.get("access_level")
|
|
374
|
+
existing_records_user_id = existing_record.get("user_id")
|
|
375
|
+
existing_records_group_id = existing_record.get("group_id")
|
|
376
|
+
existing_records_deploy_key_id = existing_record.get("deploy_key_id")
|
|
377
|
+
|
|
378
|
+
is_match = False
|
|
379
|
+
matching_item_in_patch = None
|
|
380
|
+
|
|
381
|
+
for item in patch_data:
|
|
382
|
+
# We prioritize user_id and group_id matches over access_level.
|
|
383
|
+
# This avoids ambiguity because a user/group record from GitLab also contains an access_level.
|
|
384
|
+
# If we matched by access_level first, we might incorrectly pair a specific user's rule
|
|
385
|
+
# with a generic role-based rule from the config.
|
|
386
|
+
if existing_records_user_id is not None and item.get("user_id") == existing_records_user_id:
|
|
387
|
+
is_match = True
|
|
388
|
+
elif existing_records_group_id is not None and item.get("group_id") == existing_records_group_id:
|
|
389
|
+
is_match = True
|
|
390
|
+
elif (
|
|
391
|
+
existing_records_deploy_key_id is not None
|
|
392
|
+
and item.get("deploy_key_id") == existing_records_deploy_key_id
|
|
393
|
+
):
|
|
394
|
+
is_match = True
|
|
395
|
+
elif (
|
|
396
|
+
item.get("user_id") is None
|
|
397
|
+
and item.get("group_id") is None
|
|
398
|
+
and item.get("deploy_key_id") is None
|
|
399
|
+
):
|
|
400
|
+
# Role-based rule logic
|
|
401
|
+
local_level = item.get("access_level")
|
|
402
|
+
if local_level == existing_records_access_level:
|
|
403
|
+
is_match = True
|
|
404
|
+
elif local_level == 0 or existing_records_access_level == 0:
|
|
405
|
+
# "No Access" (0) is mutually exclusive with any other role.
|
|
406
|
+
# Mark existing for destruction so the new state can be applied.
|
|
407
|
+
record_id = existing_record.get("id")
|
|
408
|
+
patch_data.append({"id": record_id, "_destroy": True})
|
|
409
|
+
break
|
|
410
|
+
|
|
411
|
+
if is_match:
|
|
412
|
+
matching_item_in_patch = item
|
|
413
|
+
break
|
|
414
|
+
|
|
415
|
+
if matching_item_in_patch:
|
|
416
|
+
# Rule already exists in GitLab, remove from the patch list to maintain idempotency
|
|
417
|
+
patch_data.remove(matching_item_in_patch)
|
|
418
|
+
|
|
419
|
+
# NOTE: GitLabForm is additive. We do not destroy existing records unless they
|
|
420
|
+
# conflict with a 'No Access' rule.
|
|
421
|
+
|
|
422
|
+
else:
|
|
423
|
+
info("No configuration defined for this access level. No changes will be made.")
|
|
424
|
+
|
|
425
|
+
return patch_data
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def naive_access_level_diff_analyzer(_, cfg_in_gitlab: list, local_cfg: list):
|
|
429
|
+
"""
|
|
430
|
+
Custom diff analyzer for branch rules.
|
|
431
|
+
|
|
432
|
+
Following GitLabForm's ADDITIVE DESIGN, an update is needed if any rule
|
|
433
|
+
defined in the local configuration is missing from GitLab.
|
|
434
|
+
"""
|
|
435
|
+
# 1. Check if any local rule is missing from GitLab (Additive check)
|
|
436
|
+
for local_item in local_cfg:
|
|
437
|
+
found = False
|
|
438
|
+
for gl_item in cfg_in_gitlab:
|
|
439
|
+
# User Match
|
|
440
|
+
if local_item.get("user_id") is not None:
|
|
441
|
+
if local_item.get("user_id") == gl_item.get("user_id"):
|
|
442
|
+
found = True
|
|
443
|
+
break
|
|
444
|
+
# Group Match
|
|
445
|
+
elif local_item.get("group_id") is not None:
|
|
446
|
+
if local_item.get("group_id") == gl_item.get("group_id"):
|
|
447
|
+
found = True
|
|
448
|
+
break
|
|
449
|
+
# Deploy Key Match
|
|
450
|
+
elif local_item.get("deploy_key_id") is not None:
|
|
451
|
+
if local_item.get("deploy_key_id") == gl_item.get("deploy_key_id"):
|
|
452
|
+
found = True
|
|
453
|
+
break
|
|
454
|
+
# Role Match (Ensure GL item is also a role rule)
|
|
455
|
+
if (
|
|
456
|
+
local_item.get("access_level") is not None
|
|
457
|
+
and local_item.get("access_level") == gl_item.get("access_level")
|
|
458
|
+
and gl_item.get("user_id") is None
|
|
459
|
+
and gl_item.get("group_id") is None
|
|
460
|
+
and gl_item.get("deploy_key_id") is None
|
|
461
|
+
):
|
|
462
|
+
found = True
|
|
463
|
+
break
|
|
464
|
+
|
|
465
|
+
if not found:
|
|
466
|
+
info("naive_access_level_diff_analyzer - needs_update: True (missing rule found)")
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
# 2. Role exclusivity check (No Access handling)
|
|
470
|
+
# Even if all local rules are "found", we need an update if GitLab has a "No Access" rule
|
|
471
|
+
# while we want specific roles, or vice-versa.
|
|
472
|
+
gl_role_levels = {
|
|
473
|
+
r.get("access_level")
|
|
474
|
+
for r in cfg_in_gitlab
|
|
475
|
+
if r.get("user_id") is None and r.get("group_id") is None and r.get("deploy_key_id") is None
|
|
476
|
+
}
|
|
477
|
+
local_role_levels = {
|
|
478
|
+
r.get("access_level")
|
|
479
|
+
for r in local_cfg
|
|
480
|
+
if r.get("user_id") is None and r.get("group_id") is None and r.get("deploy_key_id") is None
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (0 in gl_role_levels and any(lev > 0 for lev in local_role_levels)) or (
|
|
484
|
+
0 in local_role_levels and any(lev > 0 for lev in gl_role_levels)
|
|
485
|
+
):
|
|
486
|
+
info("naive_access_level_diff_analyzer - needs_update: True (No Access / Roles conflict)")
|
|
487
|
+
return True
|
|
488
|
+
|
|
489
|
+
info("naive_access_level_diff_analyzer - needs_update: False")
|
|
490
|
+
return False
|
|
491
|
+
|
|
492
|
+
@staticmethod
|
|
493
|
+
def branch_name_contains_supported_wildcard(branch):
|
|
494
|
+
"""
|
|
495
|
+
Gitlab supports "*" wildcards when protecting branches:
|
|
496
|
+
https://docs.gitlab.com/user/project/repository/branches/protected/#use-wildcard-rules
|
|
497
|
+
"""
|
|
498
|
+
return "*" in branch
|
|
499
|
+
|
|
500
|
+
@staticmethod
|
|
501
|
+
def _get_list_attribute(protected_branch: ProjectProtectedBranch, attribute_name: str) -> list[Any]:
|
|
502
|
+
"""
|
|
503
|
+
Gets list attribute such as unprotect_access_levels, merge_access_levels, push_access_levels, etc.
|
|
504
|
+
Uses the python-gitlab attributes raw dict rather than direct parameter to gracefully handle when an attribute
|
|
505
|
+
is not present in the API response.
|
|
506
|
+
For example in CE: unprotect_access_levels is not returned on the protected_branch, so trying to access directly
|
|
507
|
+
throws a runtime-exception
|
|
508
|
+
"""
|
|
509
|
+
existing_list_value: list[Any] = []
|
|
510
|
+
# Get from the "attributes" as this is the raw dict
|
|
511
|
+
existing_attr = protected_branch.attributes.get(attribute_name)
|
|
512
|
+
if existing_attr is not None:
|
|
513
|
+
existing_list_value = existing_attr
|
|
514
|
+
return existing_list_value
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from gitlabform.gitlab import GitLab
|
|
2
|
+
from gitlabform.processors.defining_keys import And, Key
|
|
3
|
+
from gitlabform.processors.multiple_entities_processor import MultipleEntitiesProcessor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeployKeysProcessor(MultipleEntitiesProcessor):
|
|
7
|
+
def __init__(self, gitlab: GitLab):
|
|
8
|
+
super().__init__(
|
|
9
|
+
"deploy_keys",
|
|
10
|
+
gitlab,
|
|
11
|
+
list_method_name="get_deploy_keys",
|
|
12
|
+
add_method_name="post_deploy_key",
|
|
13
|
+
delete_method_name="delete_deploy_key",
|
|
14
|
+
defining=Key("title"),
|
|
15
|
+
required_to_create_or_update=And(Key("title"), Key("key")),
|
|
16
|
+
# DO NOT use put_deploy_key for update as it can only update key's title,
|
|
17
|
+
# but NOT the value (according to https://docs.gitlab.com/ee/api/deploy_keys.html#update-deploy-key)
|
|
18
|
+
)
|