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