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,239 @@
1
+ import os
2
+ from logging import debug, error, warning, info
3
+ from typing import Callable, Dict, List
4
+
5
+ from gitlab import GitlabGetError, GitlabUpdateError
6
+ from gitlab.v4.objects import Project
7
+ from gql.transport.exceptions import TransportQueryError
8
+
9
+ from gitlabform.gitlab import GitLab
10
+ from gitlabform.processors.abstract_processor import AbstractProcessor
11
+ from gitlabform.processors.util.difference_logger import DifferenceLogger
12
+
13
+
14
+ class ProjectSettingsProcessor(AbstractProcessor):
15
+ def __init__(self, gitlab: GitLab, strict: bool):
16
+ super().__init__("project_settings", gitlab)
17
+ self.get_entity_in_gitlab: Callable = getattr(self, "get_project_settings")
18
+
19
+ def _process_configuration(self, project_path: str, configuration: dict) -> None:
20
+ debug("Processing project settings...")
21
+ project: Project = self.gl.get_project_by_path_cached(project_path)
22
+
23
+ project_settings_in_config = configuration.get("project_settings", {})
24
+ project_settings_in_gitlab = project.asdict()
25
+ debug(project_settings_in_gitlab)
26
+ debug("project_settings BEFORE: ^^^")
27
+
28
+ self._process_project_topics(project_settings_in_config, project_settings_in_gitlab)
29
+
30
+ # Remove avatar from config to process it last
31
+ avatar_config = project_settings_in_config.pop("avatar", None)
32
+
33
+ # Remove duo_features_enabled from config as it can't be processed via the REST Api and if it's the only
34
+ # config defined for a Project, Gitlab will reject with error the REST update() request
35
+ duo_features_enabled_in_config = project_settings_in_config.pop("duo_features_enabled", None)
36
+
37
+ # Process other settings first
38
+ if self._needs_update(project_settings_in_gitlab, project_settings_in_config):
39
+ debug("Updating project settings")
40
+ for key, value in project_settings_in_config.items():
41
+ debug(f"Updating setting {key} to value {value}")
42
+ setattr(project, key, value)
43
+ project.save()
44
+
45
+ debug(project.asdict())
46
+ debug("project_settings AFTER: ^^^")
47
+ else:
48
+ debug("No update needed for project settings")
49
+
50
+ # Process duo_features_enabled
51
+ self._process_duo_features_enabled(project, duo_features_enabled_in_config)
52
+
53
+ # Process avatar last - with error handling that doesn't stop execution
54
+ if avatar_config is not None:
55
+ try:
56
+ self._process_project_avatar(project, {"avatar": avatar_config})
57
+ except Exception as e:
58
+ warning(f"Failed to process project avatar: {e}")
59
+ raise e
60
+
61
+ def get_project_settings(self, project_path: str):
62
+ """Get project settings from GitLab."""
63
+ return self.gl.get_project_by_path_cached(project_path).asdict()
64
+
65
+ def _print_diff(self, project_or_project_and_group: str, entity_config, diff_only_changed: bool):
66
+ entity_in_gitlab = self.get_project_settings(project_or_project_and_group)
67
+
68
+ DifferenceLogger.log_diff(
69
+ f"{self.configuration_name} changes",
70
+ entity_in_gitlab,
71
+ entity_config,
72
+ only_changed=diff_only_changed,
73
+ )
74
+
75
+ def _process_project_topics(self, project_settings_in_config: Dict, project_settings_in_gitlab: Dict) -> None:
76
+ project_settings_topics: Dict = project_settings_in_config.get("topics", [])
77
+
78
+ if not project_settings_topics:
79
+ return
80
+
81
+ keep_existing: bool = False
82
+
83
+ for i, topic in enumerate(project_settings_topics):
84
+ if isinstance(topic, dict) and "keep_existing" in topic:
85
+ value = topic["keep_existing"]
86
+ if isinstance(value, bool):
87
+ keep_existing = value
88
+ del project_settings_topics[i]
89
+ break
90
+
91
+ adjusted_project_topics_to_set: List[str] = []
92
+
93
+ if keep_existing:
94
+ adjusted_project_topics_to_set.extend(project_settings_in_gitlab.get("topics", []))
95
+
96
+ # List of topics not having delete = true or no delete attribute at all
97
+ topics_to_add: List[str] = [
98
+ list(t.keys())[0] if isinstance(t, dict) else t
99
+ for t in project_settings_topics
100
+ if isinstance(t, str) or (isinstance(t, dict) and not list(t.values())[0].get("delete", False))
101
+ ]
102
+
103
+ # List of topics having delete = true
104
+ topics_to_delete: List[str] = [
105
+ list(t.keys())[0]
106
+ for t in project_settings_topics
107
+ if isinstance(t, dict) and list(t.values())[0].get("delete") is True
108
+ ]
109
+
110
+ adjusted_project_topics_to_set.extend(topics_to_add)
111
+
112
+ adjusted_project_topics_to_set = [
113
+ topic for topic in adjusted_project_topics_to_set if topic not in topics_to_delete
114
+ ]
115
+
116
+ debug(f"topics after adjustment: {adjusted_project_topics_to_set}")
117
+
118
+ project_settings_in_config["topics"] = adjusted_project_topics_to_set
119
+
120
+ def _process_project_avatar(self, project: Project, project_settings_in_config: dict) -> None:
121
+ """Process project avatar settings from configuration."""
122
+ debug("Processing project avatar configuration")
123
+
124
+ avatar_path = project_settings_in_config.get("avatar")
125
+ if avatar_path is None:
126
+ debug("No avatar configuration provided, skipping avatar processing")
127
+ return
128
+
129
+ debug(f"Avatar configuration found: {avatar_path}")
130
+
131
+ # Check current avatar status
132
+ current_avatar = getattr(project, "avatar_url", None)
133
+
134
+ if avatar_path == "":
135
+ # Want to remove avatar
136
+ if not current_avatar:
137
+ debug("Avatar already empty, no update needed")
138
+ return
139
+ debug("Deleting project avatar")
140
+ project.avatar = ""
141
+ project.save()
142
+ debug("Project avatar deleted successfully")
143
+ return
144
+
145
+ # Resolve relative paths to absolute paths
146
+ if not os.path.isabs(avatar_path):
147
+ # Convert relative path to absolute path relative to current working directory
148
+ avatar_path = os.path.abspath(avatar_path)
149
+ debug(f"Resolved relative path to absolute path: {avatar_path}")
150
+
151
+ # Want to set avatar from file
152
+ debug(f"Setting project avatar from file: {avatar_path}")
153
+ try:
154
+ with open(avatar_path, "rb") as avatar_file:
155
+ project.avatar = avatar_file
156
+ project.save()
157
+ debug("Project avatar uploaded successfully")
158
+ except FileNotFoundError:
159
+ error_msg = f"Project avatar file not found: {avatar_path}"
160
+ error(error_msg)
161
+ raise FileNotFoundError(error_msg)
162
+ except Exception as e:
163
+ error_msg = f"Error uploading project avatar: {str(e)}"
164
+ error(error_msg)
165
+ raise Exception(error_msg) from e
166
+
167
+ def _process_duo_features_enabled(self, project: Project, duo_features_enabled_in_config: None | bool):
168
+ if duo_features_enabled_in_config is None:
169
+ info(
170
+ f"duo_features_enabled is not defined in Config for project {project.path_with_namespace}, will not make any changes."
171
+ )
172
+ return
173
+
174
+ duo_features_enabled_in_gl = self._get_project_duo_features_enabled_from_gitlab(project)
175
+ if duo_features_enabled_in_gl == duo_features_enabled_in_config:
176
+ debug("No changes detected for duo_features_enabled")
177
+ return
178
+
179
+ self._set_project_duo_features_enabled(project, duo_features_enabled_in_config)
180
+
181
+ def _get_project_duo_features_enabled_from_gitlab(self, project: Project) -> List[Dict[str, str]]:
182
+ """Query GraphQL using Python Gitlab
183
+ https://python-gitlab.readthedocs.io/en/stable/api-usage-graphql.html
184
+
185
+ GETting and updating of duo_features_enabled is currently only supported through the GraphQL API:
186
+ https://gitlab.com/gitlab-org/gitlab/-/merge_requests/143972
187
+ """
188
+
189
+ query = (
190
+ """
191
+ query ProjectDuoFeatures {
192
+ project(fullPath: \""""
193
+ + project.path_with_namespace
194
+ + """\")
195
+ {
196
+ duoFeaturesEnabled
197
+ }
198
+ }
199
+ """
200
+ )
201
+ result = self.gl.graphql.execute(query)
202
+
203
+ if result["project"] is not None and result["project"]["duoFeaturesEnabled"] is not None:
204
+ return result["project"]["duoFeaturesEnabled"]
205
+ else:
206
+ raise GitlabGetError(f"Failed to get duo_features_enabled for Project: {project.path_with_namespace}")
207
+
208
+ def _set_project_duo_features_enabled(self, project: Project, duo_features_enabled: bool) -> None:
209
+ """Mutate Project Settings using GraphQL with Python Gitlab"""
210
+ duo_features_enabled_str = str(duo_features_enabled).lower()
211
+ mutation = (
212
+ """
213
+ mutation Projects {
214
+ projectSettingsUpdate(input: { fullPath: \""""
215
+ + project.path_with_namespace
216
+ + """\", duoFeaturesEnabled: """
217
+ + duo_features_enabled_str
218
+ + """ })
219
+ {
220
+ errors
221
+ }
222
+ }
223
+ """
224
+ )
225
+ try:
226
+ result = self.gl.graphql.execute(mutation)
227
+
228
+ if result["errors"] is not None:
229
+ raise GitlabUpdateError(
230
+ f"Failed to update duo_features_enabled for Project: {project.path_with_namespace}, to: {duo_features_enabled}: {result['errors']}"
231
+ )
232
+ except TransportQueryError as e:
233
+ if e.errors is not None:
234
+ error_message = e.errors[0].message
235
+ else:
236
+ error_message = "Unknown GraphQL error"
237
+ raise GitlabUpdateError(
238
+ f"Failed to update duo_features_enabled for Project: {project.path_with_namespace}, to: {duo_features_enabled}: {error_message}"
239
+ )
@@ -0,0 +1,94 @@
1
+ from typing import Dict, Any, List, cast
2
+ from logging import warning, info
3
+
4
+ import copy
5
+ import textwrap
6
+ import ez_yaml
7
+
8
+ from gitlab.exceptions import GitlabGetError
9
+ from gitlab.v4.objects import Project, ProjectVariable
10
+ from gitlabform.gitlab import GitLab
11
+ from gitlabform.processors.util.difference_logger import hide
12
+ from gitlabform.processors.abstract_processor import AbstractProcessor
13
+ from gitlabform.processors.util.variables_processor import VariablesProcessor
14
+
15
+
16
+ class ProjectVariablesProcessor(AbstractProcessor):
17
+ def __init__(self, gitlab: GitLab):
18
+ super().__init__("variables", gitlab)
19
+ self._variables_processor = VariablesProcessor(self._needs_update)
20
+
21
+ def _process_configuration(self, project_and_group: str, configuration: Dict[str, Any]) -> None:
22
+ project: Project = self.gl.get_project_by_path_cached(project_and_group)
23
+
24
+ configured_variables = configuration.get("variables", {})
25
+ enforce_mode: bool = configured_variables.get("enforce", False)
26
+
27
+ if enforce_mode:
28
+ info(f"Enforce mode enabled for variables in {project_and_group}")
29
+ # Remove 'enforce' key from the config so that it's not treated as a variable
30
+ configured_variables.pop("enforce")
31
+
32
+ self._variables_processor.process_variables(project, configured_variables, enforce_mode)
33
+
34
+ def _can_proceed(self, project_or_group: str, configuration: Dict[str, Any]) -> bool:
35
+ """Check if builds are enabled for the project."""
36
+ try:
37
+ project: Project = self.gl.get_project_by_path_cached(project_or_group)
38
+ if project.builds_access_level == "disabled":
39
+ warning("Builds disabled in this project so I can't set variables here.")
40
+ return False
41
+ return True
42
+ except GitlabGetError:
43
+ warning(f"Cannot get project settings for {project_or_group}")
44
+ return False
45
+
46
+ def _print_diff(
47
+ self,
48
+ project_and_group: str,
49
+ configuration: Dict[str, Any],
50
+ diff_only_changed: bool = False,
51
+ ) -> None:
52
+ """Print current and configured variables for comparison."""
53
+ try:
54
+ project: Project = self.gl.get_project_by_path_cached(project_and_group)
55
+ current_variables: List[ProjectVariable] = self._variables_processor.get_variables_from_gitlab(project)
56
+ variables_list: list[Dict[str, str]] = []
57
+
58
+ for variable in current_variables:
59
+ var_dict = {
60
+ "key": variable.key,
61
+ "value": hide(variable.value),
62
+ }
63
+ if hasattr(variable, "environment_scope"):
64
+ var_dict["environment_scope"] = variable.environment_scope
65
+ variables_list.append(var_dict)
66
+
67
+ info(f"Variables for {project_and_group} in GitLab:")
68
+ info(
69
+ textwrap.indent(
70
+ ez_yaml.to_string(variables_list),
71
+ " ",
72
+ )
73
+ )
74
+ except GitlabGetError:
75
+ info(f"Variables for {project_and_group} in GitLab cannot be checked.")
76
+
77
+ info(f"Variables in {project_and_group} in configuration:")
78
+
79
+ configured_variables = copy.deepcopy(configuration)
80
+ enforce_variables = configured_variables.get("enforce", False)
81
+
82
+ # Remove 'enforce' key from the config so that it's not treated as a "variable"
83
+ if enforce_variables:
84
+ configured_variables.pop("enforce")
85
+
86
+ for key in configured_variables.keys():
87
+ configured_variables[key]["value"] = hide(configured_variables[key]["value"])
88
+
89
+ info(
90
+ textwrap.indent(
91
+ ez_yaml.to_string(configured_variables),
92
+ " ",
93
+ )
94
+ )
@@ -0,0 +1,278 @@
1
+ from typing import Any, cast, Dict, Set, List, Optional
2
+ from logging import info, warning
3
+
4
+ from gitlab.exceptions import GitlabCreateError, GitlabUpdateError, GitlabDeleteError
5
+ from gitlab.v4.objects import Project, ProjectRemoteMirror
6
+ from gitlabform.gitlab import GitLab
7
+ from gitlabform.processors.abstract_processor import AbstractProcessor
8
+
9
+
10
+ class RemoteMirrorsProcessor(AbstractProcessor):
11
+ """
12
+ A processor for the "remote_mirrors" configuration section.
13
+
14
+ Allows creating, updating, and deleting remote mirrors (push mirrors) for a project.
15
+
16
+ GitLabForm follows "raw parameter passing" pattern, which means that any parameter
17
+ supported by the GitLab API for remote mirrors can be used here.
18
+ The URL containing credentials (if any) is used as the config key for each mirror.
19
+ All other attributes are passed as-is to the GitLab API.
20
+ See: https://docs.gitlab.com/ee/api/remote_mirrors.html
21
+
22
+ Additionally, the following GitLabForm-specific keys are supported:
23
+ * enforce: (boolean) If true, mirrors not defined in the config will be deleted. This is a global option, not per-mirror.
24
+ * print_details: (boolean) If true, prints the full details of all mirrors found in GitLab for the project. This is a global option.
25
+ * force_push: (boolean) If true, triggers an immediate push sync.
26
+ * force_update: (boolean) If true, forces an update call even if the config looks unchanged (useful for updating credentials).
27
+ * print_public_key: (boolean) If true, retrieves and prints the SSH public key for the mirror (if applicable).
28
+ * delete: (boolean) If true, deletes the specified mirror.
29
+
30
+ Configuration example:
31
+
32
+ remote_mirrors:
33
+ enforce: true
34
+ "https://username:password@example.com/gitlab/project.git":
35
+ enabled: true
36
+ auth_method: password
37
+ only_protected_branches: true
38
+ force_push: true
39
+ """
40
+
41
+ def __init__(self, gitlab: GitLab):
42
+ super().__init__("remote_mirrors", gitlab)
43
+
44
+ @staticmethod
45
+ def _normalize_url_for_comparison(url: str) -> str:
46
+ """Normalize URL for comparison by removing credentials.
47
+
48
+ Given a mirror URL for password-based authentication,
49
+ this method returns the corresponding URL without credentials.
50
+
51
+ Example:
52
+ http://username:password@host/path.git -> http://host/path.git
53
+
54
+ This can be used to compare mirror URLs without credentials to
55
+ find matching mirrors.
56
+ """
57
+ from urllib.parse import urlparse
58
+
59
+ parsed = urlparse(url)
60
+ # Remove credentials (user:pass@) from netloc
61
+ clean_netloc = parsed.netloc.split("@")[-1]
62
+ return parsed._replace(netloc=clean_netloc).geturl()
63
+
64
+ def _process_configuration(self, project_and_group: str, configuration: Dict[str, Any]) -> None:
65
+ project: Project = self.gl.get_project_by_path_cached(project_and_group)
66
+
67
+ # 1. PREPARATION & OPTIMIZATION
68
+ mirrors_in_gitlab: List[ProjectRemoteMirror] = project.remote_mirrors.list(get_all=True)
69
+ gitlab_mirrors_map: Dict[str, ProjectRemoteMirror] = {
70
+ self._normalize_url_for_comparison(m.url): cast(ProjectRemoteMirror, m) for m in mirrors_in_gitlab
71
+ }
72
+
73
+ mirrors_in_config: Dict[str, Any] = configuration.get("remote_mirrors", {}).copy()
74
+
75
+ # --- GLOBAL OPTIONS ---
76
+ enforce_mirrors: bool = mirrors_in_config.pop("enforce", False)
77
+ print_details: bool = mirrors_in_config.pop("print_details", False)
78
+
79
+ urls_to_keep: Set[str] = set()
80
+
81
+ # 2. PROCESS CONFIGURATION (Create / Update / Delete)
82
+ for mirror_url in sorted(mirrors_in_config.keys()):
83
+ mirror_settings: Dict[str, Any] = mirrors_in_config[mirror_url]
84
+ norm_url: str = self._normalize_url_for_comparison(mirror_url)
85
+ mirror_in_gitlab = gitlab_mirrors_map.get(norm_url)
86
+
87
+ # --- CASE: EXPLICIT DELETE ---
88
+ if mirror_settings.get("delete"):
89
+ if mirror_in_gitlab:
90
+ self._delete_remote_mirror(mirror_in_gitlab)
91
+ gitlab_mirrors_map.pop(norm_url, None)
92
+ else:
93
+ info(f"Skip deleting remote mirror '{norm_url}', because it doesn't exist")
94
+ continue
95
+
96
+ # --- CASE: CREATE OR UPDATE ---
97
+ urls_to_keep.add(norm_url)
98
+
99
+ # Prepare payload: Extract local-only options
100
+ mirror_payload: Dict[str, Any] = {"url": mirror_url, **mirror_settings}
101
+ force_push: bool = mirror_payload.pop("force_push", False)
102
+ force_update: bool = mirror_payload.pop("force_update", False)
103
+ print_public_key: bool = mirror_payload.pop("print_public_key", False)
104
+ mirror_payload.pop("delete", None)
105
+
106
+ if mirror_in_gitlab:
107
+ self._update_existing_mirror(project, mirror_in_gitlab, mirror_payload, norm_url, force_update)
108
+ else:
109
+ mirror_in_gitlab = self._create_new_mirror(project, mirror_payload, mirror_url)
110
+
111
+ if print_public_key and mirror_in_gitlab:
112
+ self._handle_public_key_display(project, mirror_in_gitlab, norm_url)
113
+
114
+ if force_push and mirror_in_gitlab:
115
+ self._sync_remote_mirror(mirror_in_gitlab)
116
+
117
+ # 3. ENFORCEMENT PHASE
118
+ if enforce_mirrors:
119
+ self._enforce_mirrors(gitlab_mirrors_map, urls_to_keep)
120
+
121
+ # 4. REPORTING PHASE (Final State)
122
+ if print_details:
123
+ # We fetch a fresh list to show the final state after all updates/syncs
124
+ final_mirrors: List[ProjectRemoteMirror] = project.remote_mirrors.list(get_all=True)
125
+ if not final_mirrors:
126
+ info("🔍 No remote mirrors found for this project.")
127
+ else:
128
+ info(f"📋 Final Remote Mirror Report for '{project_and_group}':")
129
+ for mirror in final_mirrors:
130
+ info(" " + "─" * 30) # Visual separator using a light line
131
+ self._report_mirror_details(mirror)
132
+ info(" " + "─" * 30)
133
+
134
+ def _handle_public_key_display(self, project: Project, mirror_obj: ProjectRemoteMirror, norm_url: str) -> None:
135
+ """
136
+ Retrieves and prints the SSH public key for a mirror.
137
+ GitLab only provides this for mirrors configured with 'ssh_public_key' auth.
138
+ """
139
+ # Only attempt retrieval if the auth method supports it
140
+ if getattr(mirror_obj, "auth_method", None) != "ssh_public_key":
141
+ info(f"Skipping public key display for '{norm_url}': auth_method is not 'ssh_public_key'")
142
+ return
143
+
144
+ public_key: Optional[str] = None
145
+ try:
146
+ # TODO: python-gitlab does not yet support retrieving the public key via
147
+ # ProjectRemoteMirror object (e.g., mirror_obj.get_public_key()).
148
+ # Switch to native method once supported in the library.
149
+
150
+ # Mypy fix: cast the union return type (dict | Response) to dict[str, Any]
151
+ response = cast(
152
+ Dict[str, Any],
153
+ project.manager.gitlab.http_get(f"/projects/{project.id}/remote_mirrors/{mirror_obj.id}/public_key"),
154
+ )
155
+ public_key = response.get("public_key")
156
+ except Exception as e:
157
+ warning(f"Failed to retrieve SSH public key for mirror {norm_url}: {e}")
158
+
159
+ if public_key:
160
+ info(f"🔑 SSH Public Key for mirror '{norm_url}':")
161
+ info(public_key)
162
+ info("👆 This public key must be added to the target repository to authorize the mirror.")
163
+ info(
164
+ "Please consult the GitLab documentation on 'Repository Mirroring' for specific setup instructions for your target platform."
165
+ )
166
+ else:
167
+ info(f"No public key available to display for mirror '{norm_url}'")
168
+
169
+ def _needs_update(self, existing_mirror: Dict[str, Any], config_payload: Dict[str, Any]) -> bool:
170
+ """
171
+ Overrides the base comparison to handle GitLab's URL credential masking.
172
+ Normalization is applied so that 'user:pass@host' matches '*****:*****@host'.
173
+ """
174
+ comparison_payload: Dict[str, Any] = config_payload.copy()
175
+ if "url" in comparison_payload:
176
+ comparison_payload["url"] = self._normalize_url_for_comparison(comparison_payload["url"])
177
+
178
+ existing_mirror_dict = existing_mirror.copy()
179
+
180
+ existing_mirror_dict["url"] = self._normalize_url_for_comparison(existing_mirror_dict.get("url", ""))
181
+
182
+ return super()._needs_update(existing_mirror_dict, comparison_payload)
183
+
184
+ def _update_existing_mirror(
185
+ self,
186
+ project: Project,
187
+ mirror_obj: ProjectRemoteMirror,
188
+ payload: Dict[str, Any],
189
+ norm_url: str,
190
+ force_update: bool,
191
+ ) -> None:
192
+ """Compares and updates an existing mirror if changed or if force_update is set."""
193
+
194
+ should_update: bool = force_update or self._needs_update(mirror_obj.asdict(), payload)
195
+
196
+ if should_update:
197
+ if force_update:
198
+ info(f"Mirror '{norm_url}' update is being forced via 'force_update' flag.")
199
+
200
+ info(f"Updating remote mirror '{norm_url}' with latest config")
201
+ try:
202
+ project.remote_mirrors.update(id=mirror_obj.id, new_data=payload)
203
+ info(f"Updated remote mirror '{norm_url}'")
204
+
205
+ if force_update:
206
+ info(
207
+ f"!!! REMINDER: 'force_update' was used for mirror '{norm_url}'. "
208
+ "Please remove this flag from your configuration to avoid unnecessary API calls in future runs."
209
+ )
210
+ except GitlabUpdateError as e:
211
+ warning(f"Failed to update remote mirror {norm_url}: {e}")
212
+ else:
213
+ info(f"Remote mirror '{norm_url}' remains unchanged")
214
+
215
+ def _create_new_mirror(
216
+ self, project: Project, payload: Dict[str, Any], raw_url: str
217
+ ) -> Optional[ProjectRemoteMirror]:
218
+ """Creates a new remote mirror and handles API errors."""
219
+ norm_url = self._normalize_url_for_comparison(raw_url)
220
+ info(f"Creating remote mirror '{norm_url}'")
221
+ try:
222
+ return cast(ProjectRemoteMirror, project.remote_mirrors.create(payload))
223
+ except GitlabCreateError as e:
224
+ warning(f"Failed to create remote mirror {norm_url}: {e}")
225
+ return None
226
+
227
+ def _enforce_mirrors(self, gitlab_mirrors_map: Dict[str, ProjectRemoteMirror], urls_to_keep: Set[str]) -> None:
228
+ """Deletes mirrors present in GitLab that are not in the configuration."""
229
+ for norm_url, gm in gitlab_mirrors_map.items():
230
+ if norm_url not in urls_to_keep:
231
+ info(f"Enforce: Deleting remote mirror '{gm.url}' as it is not in the configuration")
232
+ self._delete_remote_mirror(gm)
233
+
234
+ def _delete_remote_mirror(self, mirror: ProjectRemoteMirror) -> None:
235
+ """Delete the given `ProjectRemoteMirror` and handle errors."""
236
+ info(f"Deleting remote mirror '{mirror.url}'")
237
+ try:
238
+ mirror.delete()
239
+ except GitlabDeleteError as e:
240
+ warning(
241
+ f"Failed to delete remote mirror id={getattr(mirror, 'id', None)} url={getattr(mirror, 'url', None)}: {e}"
242
+ )
243
+ info(f"Failed to delete remote mirror '{mirror.url}'")
244
+
245
+ def _sync_remote_mirror(self, mirror: ProjectRemoteMirror) -> None:
246
+ """Trigger sync for remote mirror when `force_push` is requested."""
247
+ mirror_id = getattr(mirror, "id", None)
248
+ mirror_url = getattr(mirror, "url", None)
249
+ info(f"Attempting sync for remote mirror id={mirror_id} url={mirror_url}")
250
+
251
+ try:
252
+ result = mirror.sync()
253
+ info(f"Triggered sync for remote mirror '{mirror_url}' result={result}")
254
+ except Exception as e:
255
+ warning(f"Failed to trigger sync for remote mirror id={mirror_id} url={mirror_url}: {e}")
256
+
257
+ def _report_mirror_details(self, mirror: ProjectRemoteMirror) -> None:
258
+ """Prints every attribute of the mirror object, one per line."""
259
+
260
+ mirror_data = mirror.asdict()
261
+
262
+ # Mapping statuses to helpful visual cues
263
+ status_icons = {
264
+ "finished": "✅",
265
+ "started": "⏳",
266
+ "scheduled": "📅",
267
+ "failed": "❌",
268
+ "none": "⚪",
269
+ }
270
+
271
+ for key, value in sorted(mirror_data.items()):
272
+ if key == "update_status":
273
+ icon = status_icons.get(value, "❓")
274
+ info(f" - {key}: {icon} {value}")
275
+ elif key == "last_error" and value:
276
+ info(f" - {key}: ⚠️ {value}")
277
+ else:
278
+ info(f" - {key}: {value}")
@@ -0,0 +1,48 @@
1
+ from logging import warning, info
2
+ from gitlabform.gitlab import GitLab
3
+ from gitlabform.processors.abstract_processor import AbstractProcessor
4
+
5
+ from gitlab import GitlabGetError, GitlabUpdateError
6
+ from gitlab.v4.objects import Project, ProjectResourceGroup
7
+
8
+
9
+ class ResourceGroupsProcessor(AbstractProcessor):
10
+ def __init__(self, gitlab: GitLab):
11
+ super().__init__("resource_groups", gitlab)
12
+
13
+ def _process_configuration(self, project_and_group: str, configuration: dict):
14
+ configured_resource_groups: dict = configuration.get("resource_groups", {})
15
+ ensure_exists: bool = configuration.get("resource_groups|ensure_exists", True)
16
+
17
+ # Remove 'ensure_exists' from config so that it's not treated as a 'resource group'
18
+ if "ensure_exists" in configured_resource_groups:
19
+ configured_resource_groups.pop("ensure_exists")
20
+
21
+ project: Project = self.gl.get_project_by_path_cached(project_and_group)
22
+
23
+ for config_resource_group_name in sorted(configured_resource_groups):
24
+ resource_group_in_config = configured_resource_groups[config_resource_group_name]
25
+ try:
26
+ resource_group_in_gitlab: ProjectResourceGroup = project.resource_groups.get(config_resource_group_name)
27
+ except GitlabGetError:
28
+ message = (
29
+ f"Project is not configured to use resource group: {config_resource_group_name}.\n"
30
+ f"Add the resource group in your project's .gitlab-ci.yml file.\n"
31
+ f"For more information, visit https://docs.gitlab.com/ee/ci/resource_groups/#add-a-resource-group.\n"
32
+ f"Or add 'ensure_exists: false' to gitlabform config to continue processing.\n"
33
+ f"For more information, visit https://gitlabform.github.io/gitlabform/reference/resource_groups\n"
34
+ )
35
+ if ensure_exists:
36
+ raise Exception(message)
37
+ else:
38
+ warning(message)
39
+ continue
40
+
41
+ if self._needs_update(resource_group_in_gitlab.asdict(), resource_group_in_config):
42
+ info(f"Updating resource group '{config_resource_group_name}'")
43
+
44
+ try:
45
+ project.resource_groups.update(resource_group_in_gitlab.key, **resource_group_in_config)
46
+ except GitlabUpdateError as error:
47
+ warning(f"Resource group update failed. Error: '{error}'")
48
+ raise GitlabUpdateError