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,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
+ )