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,208 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import re
|
|
3
|
+
from logging import debug
|
|
4
|
+
from typing import Dict, List
|
|
5
|
+
|
|
6
|
+
from gitlab.base import RESTObjectList, RESTObject
|
|
7
|
+
from gitlab.v4.objects import Project, ProjectPipelineSchedule
|
|
8
|
+
|
|
9
|
+
from gitlabform.gitlab import GitLab
|
|
10
|
+
from gitlabform.processors.abstract_processor import AbstractProcessor
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SchedulesProcessor(AbstractProcessor):
|
|
14
|
+
def __init__(self, gitlab: GitLab):
|
|
15
|
+
super().__init__("schedules", gitlab)
|
|
16
|
+
|
|
17
|
+
def _process_configuration(self, project_and_group: str, configuration: Dict):
|
|
18
|
+
configured_schedules = configuration.get("schedules", {})
|
|
19
|
+
|
|
20
|
+
enforce_schedules = configuration.get("schedules|enforce", False)
|
|
21
|
+
|
|
22
|
+
# Remove 'enforce' key from the config so that it's not treated as a "schedule"
|
|
23
|
+
if enforce_schedules:
|
|
24
|
+
configured_schedules.pop("enforce")
|
|
25
|
+
|
|
26
|
+
project: Project = self.gl.get_project_by_path_cached(project_and_group)
|
|
27
|
+
existing_schedules: list[ProjectPipelineSchedule] = project.pipelineschedules.list(get_all=True)
|
|
28
|
+
|
|
29
|
+
schedule_ids_by_description: Dict = self._group_schedule_ids_by_description(existing_schedules)
|
|
30
|
+
|
|
31
|
+
for schedule_description in sorted(configured_schedules):
|
|
32
|
+
schedule_ids = schedule_ids_by_description.get(schedule_description)
|
|
33
|
+
|
|
34
|
+
if configured_schedules[schedule_description].get("delete"):
|
|
35
|
+
if schedule_ids:
|
|
36
|
+
debug("Deleting pipeline schedules '%s'", schedule_description)
|
|
37
|
+
for schedule_id in schedule_ids:
|
|
38
|
+
project.pipelineschedules.get(schedule_id).delete()
|
|
39
|
+
else:
|
|
40
|
+
debug(
|
|
41
|
+
"Not deleting pipeline schedules '%s', because none exist",
|
|
42
|
+
schedule_description,
|
|
43
|
+
)
|
|
44
|
+
else:
|
|
45
|
+
entity_config = configured_schedules[schedule_description]
|
|
46
|
+
|
|
47
|
+
if schedule_ids and len(schedule_ids) == 1:
|
|
48
|
+
schedule = project.pipelineschedules.get(schedule_ids[0])
|
|
49
|
+
self._update_existing_schedule(entity_config, project, schedule, schedule_description)
|
|
50
|
+
elif schedule_ids:
|
|
51
|
+
debug(
|
|
52
|
+
"Replacing existing pipeline schedules '%s'",
|
|
53
|
+
schedule_description,
|
|
54
|
+
)
|
|
55
|
+
for schedule_id in schedule_ids:
|
|
56
|
+
project.pipelineschedules.get(schedule_id).delete()
|
|
57
|
+
|
|
58
|
+
self._create_schedule_with_variables(entity_config, project, schedule_description)
|
|
59
|
+
else:
|
|
60
|
+
debug("Creating pipeline schedule '%s'", schedule_description)
|
|
61
|
+
self._create_schedule_with_variables(entity_config, project, schedule_description)
|
|
62
|
+
|
|
63
|
+
if enforce_schedules:
|
|
64
|
+
debug("Delete unconfigured schedules because enforce is enabled")
|
|
65
|
+
|
|
66
|
+
self._delete_schedules_no_longer_in_config(configured_schedules, existing_schedules, project)
|
|
67
|
+
|
|
68
|
+
def _update_existing_schedule(
|
|
69
|
+
self,
|
|
70
|
+
entity_config: Dict,
|
|
71
|
+
project: Project,
|
|
72
|
+
schedule_in_gitlab: ProjectPipelineSchedule,
|
|
73
|
+
schedule_description: str,
|
|
74
|
+
):
|
|
75
|
+
entity_config["cron"] = _replace_extended_cron_pattern(project.id, entity_config["cron"])
|
|
76
|
+
if self._needs_update(schedule_in_gitlab.asdict(), entity_config):
|
|
77
|
+
debug("Changing existing pipeline schedule '%s'", schedule_description)
|
|
78
|
+
# In order to edit a Schedule created by someone else we need to take ownership:
|
|
79
|
+
# https://docs.gitlab.com/ee/ci/pipelines/schedules.html#take-ownership
|
|
80
|
+
shedule = project.pipelineschedules.get(schedule_in_gitlab.id)
|
|
81
|
+
shedule.take_ownership()
|
|
82
|
+
project.pipelineschedules.update(
|
|
83
|
+
schedule_in_gitlab.id,
|
|
84
|
+
{"description": schedule_description, **entity_config},
|
|
85
|
+
)
|
|
86
|
+
configured_variables = entity_config.get("variables")
|
|
87
|
+
if configured_variables is not None:
|
|
88
|
+
self._set_schedule_variables(
|
|
89
|
+
schedule_in_gitlab,
|
|
90
|
+
configured_variables,
|
|
91
|
+
)
|
|
92
|
+
else:
|
|
93
|
+
debug("No update required for pipeline schedule '%s'", schedule_description)
|
|
94
|
+
|
|
95
|
+
def _create_schedule_with_variables(
|
|
96
|
+
self,
|
|
97
|
+
entity_config: Dict,
|
|
98
|
+
project: Project,
|
|
99
|
+
schedule_description: str,
|
|
100
|
+
):
|
|
101
|
+
entity_config["cron"] = _replace_extended_cron_pattern(project.id, entity_config["cron"])
|
|
102
|
+
schedule_data = {"description": schedule_description, **entity_config}
|
|
103
|
+
debug("Creating pipeline schedule using data: '%s'", schedule_data)
|
|
104
|
+
created_schedule_id = project.pipelineschedules.create(schedule_data).id
|
|
105
|
+
created_schedule = project.pipelineschedules.get(created_schedule_id)
|
|
106
|
+
|
|
107
|
+
configured_variables = entity_config.get("variables")
|
|
108
|
+
if configured_variables is not None:
|
|
109
|
+
self._set_schedule_variables(
|
|
110
|
+
created_schedule,
|
|
111
|
+
configured_variables,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
created_schedule.save()
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _set_schedule_variables(schedule: ProjectPipelineSchedule, variables: Dict) -> None:
|
|
118
|
+
attributes = schedule.attributes
|
|
119
|
+
existing_variables = attributes.get("variables")
|
|
120
|
+
if existing_variables:
|
|
121
|
+
debug("Deleting variables for pipeline schedule '%s'", schedule.description)
|
|
122
|
+
|
|
123
|
+
for variable in existing_variables:
|
|
124
|
+
schedule.variables.delete(variable.get("key"))
|
|
125
|
+
|
|
126
|
+
if variables:
|
|
127
|
+
for variable_key, variable_data in variables.items():
|
|
128
|
+
schedule.variables.create({"key": variable_key, **variable_data})
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _group_schedule_ids_by_description(
|
|
132
|
+
schedules,
|
|
133
|
+
) -> Dict[str, List[str]]:
|
|
134
|
+
schedule_ids_by_description: Dict[str, List[str]] = {}
|
|
135
|
+
|
|
136
|
+
for schedule in schedules:
|
|
137
|
+
schedule_ids_by_description.setdefault(schedule.description, []).append(schedule.id)
|
|
138
|
+
|
|
139
|
+
return schedule_ids_by_description
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _delete_schedules_no_longer_in_config(configured_schedules: Dict, existing_schedules, project: Project) -> None:
|
|
143
|
+
schedule: ProjectPipelineSchedule
|
|
144
|
+
for schedule in existing_schedules:
|
|
145
|
+
schedule_description = schedule.description
|
|
146
|
+
schedule_id = schedule.id
|
|
147
|
+
|
|
148
|
+
debug(f"processing {schedule_id}: {schedule_description}")
|
|
149
|
+
if schedule_description not in configured_schedules:
|
|
150
|
+
debug(
|
|
151
|
+
"Deleting pipeline schedule named '%s', because it is not in gitlabform configuration",
|
|
152
|
+
schedule_description,
|
|
153
|
+
)
|
|
154
|
+
project.pipelineschedules.get(schedule_id).delete()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class ExtendedCronPattern:
|
|
158
|
+
|
|
159
|
+
def __init__(self, project_id: int, cron_expression: str):
|
|
160
|
+
self._h_pattern = re.compile(r"H(?:(\((?P<start>\d+)-(?P<end>\d+)\))|(?P<interval>/\d+))?")
|
|
161
|
+
# We do use random here to achieve a stable pseudo-random value, this is not security relevant
|
|
162
|
+
# Seeding with project_id always returns the same numbers, that is what we want here.
|
|
163
|
+
self._random = random.Random() # nosec B311
|
|
164
|
+
self._random.seed(project_id, 2)
|
|
165
|
+
self._cron_parts = cron_expression.split()
|
|
166
|
+
if len(self._cron_parts) != 5:
|
|
167
|
+
raise ValueError(f"Expected 5 parts in the cron expression, got {self._cron_parts}")
|
|
168
|
+
|
|
169
|
+
def render(self) -> str:
|
|
170
|
+
self._cron_parts[0] = self._detect_and_replace_h(self._cron_parts[0], 60)
|
|
171
|
+
self._cron_parts[1] = self._detect_and_replace_h(self._cron_parts[1], 24)
|
|
172
|
+
self._cron_parts[4] = self._detect_and_replace_h(self._cron_parts[4], 7)
|
|
173
|
+
return " ".join(self._cron_parts)
|
|
174
|
+
|
|
175
|
+
def _detect_and_replace_h(self, cron_part: str, default_max: int):
|
|
176
|
+
parts = cron_part.split(",")
|
|
177
|
+
for i, _ in enumerate(parts):
|
|
178
|
+
match = self._h_pattern.match(parts[i])
|
|
179
|
+
if match:
|
|
180
|
+
self._replace_h(i, parts, match, default_max)
|
|
181
|
+
return ",".join(parts)
|
|
182
|
+
|
|
183
|
+
def _replace_h(self, i: int, parts: List[str], match: re.Match, default_max: int):
|
|
184
|
+
interval = match.group("interval") or ""
|
|
185
|
+
if interval:
|
|
186
|
+
interval = int(interval[1:])
|
|
187
|
+
times = int(default_max / interval)
|
|
188
|
+
first = self._random.randint(0, interval)
|
|
189
|
+
result = [str(first)]
|
|
190
|
+
result.extend(str(first + i * interval) for i in range(1, times))
|
|
191
|
+
else:
|
|
192
|
+
start = int(match.group("start") or 0)
|
|
193
|
+
end = int(match.group("end") or default_max - 1)
|
|
194
|
+
result = [str(self._random.randint(start, end))]
|
|
195
|
+
parts[i] = parts[i].replace(match.string, ",".join(result))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
EXTENDED_CRON_PATTERN_ALIASES = {
|
|
199
|
+
"@hourly": "H * * * *",
|
|
200
|
+
"@daily": "H H * * *",
|
|
201
|
+
"@weekly": "H H * * H",
|
|
202
|
+
"@nightly": "H H(00-06) * * *",
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _replace_extended_cron_pattern(project_id: int, cron_expression: str) -> str:
|
|
207
|
+
cron_expression = EXTENDED_CRON_PATTERN_ALIASES.get(cron_expression.lower(), cron_expression)
|
|
208
|
+
return ExtendedCronPattern(project_id, cron_expression).render()
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from logging import debug, warning, critical, error
|
|
3
|
+
|
|
4
|
+
from gitlabform.constants import EXIT_PROCESSING_ERROR
|
|
5
|
+
from gitlabform.gitlab import GitLab
|
|
6
|
+
from gitlab import GitlabDeleteError, GitlabGetError
|
|
7
|
+
from gitlabform.processors.abstract_processor import AbstractProcessor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TagsProcessor(AbstractProcessor):
|
|
11
|
+
def __init__(self, gitlab: GitLab, strict: bool):
|
|
12
|
+
super().__init__("tags", gitlab)
|
|
13
|
+
self.strict = strict
|
|
14
|
+
|
|
15
|
+
def _process_configuration(self, project_and_group: str, configuration: dict):
|
|
16
|
+
project = self.gl.get_project_by_path_cached(name=project_and_group, lazy=True)
|
|
17
|
+
|
|
18
|
+
for tag in sorted(configuration["tags"]):
|
|
19
|
+
try:
|
|
20
|
+
if configuration["tags"][tag]["protected"]:
|
|
21
|
+
allowed_to_create = []
|
|
22
|
+
|
|
23
|
+
if "allowed_to_create" in configuration["tags"][tag]:
|
|
24
|
+
access_levels = set()
|
|
25
|
+
user_ids = set()
|
|
26
|
+
group_ids = set()
|
|
27
|
+
|
|
28
|
+
requested_configuration = configuration["tags"][tag]["allowed_to_create"]
|
|
29
|
+
|
|
30
|
+
for config in requested_configuration:
|
|
31
|
+
if "access_level" in config:
|
|
32
|
+
access_levels.add(config["access_level"])
|
|
33
|
+
elif "user_id" in config:
|
|
34
|
+
user_ids.add(config["user_id"])
|
|
35
|
+
elif "user" in config:
|
|
36
|
+
user_id = self.gl.get_user_id_cached(config["user"])
|
|
37
|
+
if user_id is None:
|
|
38
|
+
error(
|
|
39
|
+
f"Could not find User '{config["user"]}' on the Instance, cannot Protect "
|
|
40
|
+
f"Tag with them"
|
|
41
|
+
)
|
|
42
|
+
raise GitlabGetError(
|
|
43
|
+
f"_process_configuration - No users found when searching for username {config["user"]}",
|
|
44
|
+
404,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
user_ids.add(user_id)
|
|
48
|
+
elif "group_id" in config:
|
|
49
|
+
group_ids.add(config["group_id"])
|
|
50
|
+
elif "group" in config:
|
|
51
|
+
try:
|
|
52
|
+
gitlab_group = self.gl.get_group_by_path_cached(config["group"])
|
|
53
|
+
except GitlabGetError as e:
|
|
54
|
+
error(
|
|
55
|
+
f"Could not find Group '{config["group"]}' on the Instance, cannot Protect "
|
|
56
|
+
f"Tag with them"
|
|
57
|
+
)
|
|
58
|
+
raise e
|
|
59
|
+
|
|
60
|
+
group_ids.add(gitlab_group.get_id())
|
|
61
|
+
|
|
62
|
+
for val in access_levels:
|
|
63
|
+
allowed_to_create.append({"access_level": val})
|
|
64
|
+
|
|
65
|
+
for val in user_ids:
|
|
66
|
+
allowed_to_create.append({"user_id": val})
|
|
67
|
+
|
|
68
|
+
for val in group_ids:
|
|
69
|
+
allowed_to_create.append({"group_id": val})
|
|
70
|
+
|
|
71
|
+
create_access_level = (
|
|
72
|
+
configuration["tags"][tag]["create_access_level"]
|
|
73
|
+
if "create_access_level" in configuration["tags"][tag]
|
|
74
|
+
else None
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
debug("Setting tag '%s' as *protected*", tag)
|
|
78
|
+
try:
|
|
79
|
+
# try to unprotect first
|
|
80
|
+
project.protectedtags.delete(tag)
|
|
81
|
+
except GitlabDeleteError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
data = {}
|
|
85
|
+
data["name"] = tag
|
|
86
|
+
if allowed_to_create is not None:
|
|
87
|
+
data["allowed_to_create"] = allowed_to_create
|
|
88
|
+
if create_access_level is not None:
|
|
89
|
+
data["create_access_level"] = create_access_level
|
|
90
|
+
project.protectedtags.create(data)
|
|
91
|
+
else:
|
|
92
|
+
debug("Setting tag '%s' as *unprotected*", tag)
|
|
93
|
+
project.protectedtags.delete(tag)
|
|
94
|
+
except GitlabDeleteError:
|
|
95
|
+
message = f"Tag '{tag}' not found when trying to unprotect it!"
|
|
96
|
+
if self.strict:
|
|
97
|
+
critical(message)
|
|
98
|
+
sys.exit(EXIT_PROCESSING_ERROR)
|
|
99
|
+
else:
|
|
100
|
+
warning(message)
|
|
101
|
+
except GitlabGetError as e:
|
|
102
|
+
if self.strict:
|
|
103
|
+
critical(
|
|
104
|
+
e,
|
|
105
|
+
)
|
|
106
|
+
sys.exit(EXIT_PROCESSING_ERROR)
|
|
107
|
+
else:
|
|
108
|
+
warning(message)
|
|
File without changes
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from gitlabform.gitlab import GitLab
|
|
2
|
+
from gitlabform.processors.defining_keys import Key, And
|
|
3
|
+
from gitlabform.processors.multiple_entities_processor import MultipleEntitiesProcessor
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ProtectedEnvironmentsProcessor(MultipleEntitiesProcessor):
|
|
7
|
+
"""https://docs.gitlab.com/ee/api/protected_environments.html#protect-repository-environments"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, gitlab: GitLab):
|
|
10
|
+
super().__init__(
|
|
11
|
+
"protected_environments",
|
|
12
|
+
gitlab,
|
|
13
|
+
list_method_name=gitlab.list_protected_environments,
|
|
14
|
+
add_method_name=gitlab.protect_a_repository_environment,
|
|
15
|
+
delete_method_name=gitlab.unprotect_environment,
|
|
16
|
+
defining=Key("name"),
|
|
17
|
+
required_to_create_or_update=And(Key("name"), Key("deploy_access_levels")),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
self.custom_diff_analyzers["deploy_access_levels"] = self.recursive_diff_analyzer
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SafeDict(dict):
|
|
5
|
+
"""
|
|
6
|
+
A dict that a "get" method that allows to use a path-like reference to its subdict values.
|
|
7
|
+
|
|
8
|
+
For example with a dict like {"key": {"subkey": {"subsubkey": "value"}}}
|
|
9
|
+
you can use a string 'key|subkey|subsubkey' to get the 'value'.
|
|
10
|
+
|
|
11
|
+
The default value is returned if ANY of the subelements does not exist.
|
|
12
|
+
|
|
13
|
+
Code based on https://stackoverflow.com/a/44859638/2693875
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def get(self, path, default=None):
|
|
17
|
+
keys = path.split("|")
|
|
18
|
+
val = None
|
|
19
|
+
|
|
20
|
+
for key in keys:
|
|
21
|
+
if val:
|
|
22
|
+
if isinstance(val, list):
|
|
23
|
+
val = [v.get(key, default) if v else None for v in val]
|
|
24
|
+
else:
|
|
25
|
+
val = val.get(key, default)
|
|
26
|
+
else:
|
|
27
|
+
val = dict.get(self, key, default)
|
|
28
|
+
|
|
29
|
+
if not val:
|
|
30
|
+
break
|
|
31
|
+
|
|
32
|
+
return val
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def configuration_to_safe_dict(method):
|
|
36
|
+
"""
|
|
37
|
+
This wrapper function calls the method with the configuration converted from a regular dict into a SafeDict
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
@wraps(method)
|
|
41
|
+
def method_wrapper(self, project_and_group, configuration, *args):
|
|
42
|
+
return method(self, project_and_group, SafeDict(configuration), *args)
|
|
43
|
+
|
|
44
|
+
return method_wrapper
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import json
|
|
3
|
+
from itertools import starmap
|
|
4
|
+
|
|
5
|
+
from logging import info
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Simple function to create strings for values which should be hidden
|
|
9
|
+
# example: <secret b2c1a982>
|
|
10
|
+
def hide(text: str):
|
|
11
|
+
return f"<secret {hashlib.sha256(text.encode('utf-8')).hexdigest()[:8]}>"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DifferenceLogger:
|
|
15
|
+
@staticmethod
|
|
16
|
+
def log_diff(
|
|
17
|
+
subject,
|
|
18
|
+
current_config,
|
|
19
|
+
config_to_apply,
|
|
20
|
+
only_changed=False,
|
|
21
|
+
hide_entries=None,
|
|
22
|
+
test=False,
|
|
23
|
+
):
|
|
24
|
+
# Compose values in list of `[key, from_config, from_server]``
|
|
25
|
+
changes = [
|
|
26
|
+
[
|
|
27
|
+
k,
|
|
28
|
+
json.dumps(current_config.get(k, "???") if isinstance(current_config, dict) else "???"),
|
|
29
|
+
json.dumps(v),
|
|
30
|
+
]
|
|
31
|
+
for k, v in config_to_apply.items()
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# Remove unchanged if needed
|
|
35
|
+
if only_changed:
|
|
36
|
+
# due to `filter` returning an iterator, we have to wrap it
|
|
37
|
+
# in `list()` to get the values and assign back to `changes`,
|
|
38
|
+
# otherwise `changes` is not what we expect it to be later :)
|
|
39
|
+
changes = list(filter(lambda i: i[1] != i[2], changes))
|
|
40
|
+
|
|
41
|
+
# Hide secrets
|
|
42
|
+
if hide_entries:
|
|
43
|
+
changes = list(
|
|
44
|
+
map(
|
|
45
|
+
lambda i: ([i[0], hide(i[1]), hide(i[2])] if i[0] in hide_entries else i),
|
|
46
|
+
changes,
|
|
47
|
+
)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# There is the potential that no changes need to be shown, which
|
|
51
|
+
# results in the calls to max() later on to fail. Instead, opting
|
|
52
|
+
# to return early with an emtpy string, since no changes were identified
|
|
53
|
+
if len(changes) == 0:
|
|
54
|
+
return ""
|
|
55
|
+
|
|
56
|
+
# calculate field size for nice formatting
|
|
57
|
+
max_key_len = str(max(map(lambda i: len(i[0]), changes)))
|
|
58
|
+
max_val_1 = str(max(map(lambda i: len(i[1]), changes)))
|
|
59
|
+
max_val_2 = str(max(map(lambda i: len(i[2]), changes)))
|
|
60
|
+
|
|
61
|
+
# generate placeholders for output pattern: ` value: before => after `
|
|
62
|
+
pattern = "{:>" + max_key_len + "}: {:<" + max_val_1 + "} => {:<" + max_val_2 + "}"
|
|
63
|
+
|
|
64
|
+
# create string
|
|
65
|
+
text = "{subject}:\n{diff}".format(subject=subject, diff="\n".join(starmap(pattern.format, changes)))
|
|
66
|
+
|
|
67
|
+
if test:
|
|
68
|
+
return text
|
|
69
|
+
else:
|
|
70
|
+
info(text)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from logging import info, warning
|
|
2
|
+
from typing import Dict, List, Callable, Union
|
|
3
|
+
|
|
4
|
+
from gitlab.v4.objects import Group, Project, ProjectLabel, GroupLabel
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LabelsProcessor:
|
|
8
|
+
|
|
9
|
+
# Groups and Projects share the same API for .labels within python-gitlab
|
|
10
|
+
def process_labels(
|
|
11
|
+
self,
|
|
12
|
+
configured_labels: Dict,
|
|
13
|
+
enforce: bool,
|
|
14
|
+
group_or_project: Group | Project,
|
|
15
|
+
needs_update: Callable, # self._needs_update passed from AbstractProcessor called process_labels
|
|
16
|
+
):
|
|
17
|
+
# Only get Labels created directly on the project/group
|
|
18
|
+
existing_group_labels = group_or_project.labels.list(get_all=True, include_ancestor_groups=False)
|
|
19
|
+
existing_group_and_parent_labels = group_or_project.labels.list(get_all=True)
|
|
20
|
+
existing_label_keys: List = []
|
|
21
|
+
|
|
22
|
+
if isinstance(group_or_project, Group):
|
|
23
|
+
parent_object_type = "Group"
|
|
24
|
+
else:
|
|
25
|
+
parent_object_type = "Project"
|
|
26
|
+
|
|
27
|
+
gitlab_labels_to_delete: List = []
|
|
28
|
+
|
|
29
|
+
for label_to_update in existing_group_labels:
|
|
30
|
+
label_name_in_gl = label_to_update.name
|
|
31
|
+
updated_label = False
|
|
32
|
+
info(f"Checking if {label_name_in_gl} is in Configuration to update or delete")
|
|
33
|
+
|
|
34
|
+
for key, configured_label in configured_labels.items():
|
|
35
|
+
configured_label_name = configured_label.get("name")
|
|
36
|
+
# Key in YAML may not match the "name" value in Gitlab or YAML, so we must match on both
|
|
37
|
+
if self.configured_label_matches_gitlab_label(configured_label_name, key, label_name_in_gl):
|
|
38
|
+
# label exists in GL, so update
|
|
39
|
+
existing_label_keys.append(key)
|
|
40
|
+
updated_label = True
|
|
41
|
+
|
|
42
|
+
if needs_update(label_to_update.asdict(), configured_label):
|
|
43
|
+
self.update_existing_label(
|
|
44
|
+
configured_label,
|
|
45
|
+
self.get_label(group_or_project, label_to_update),
|
|
46
|
+
parent_object_type,
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
info(f"No update required for label: {label_name_in_gl}")
|
|
50
|
+
break
|
|
51
|
+
|
|
52
|
+
if not updated_label:
|
|
53
|
+
gitlab_labels_to_delete.append(label_to_update)
|
|
54
|
+
|
|
55
|
+
# Delete labels no longer in config
|
|
56
|
+
for label_to_delete in gitlab_labels_to_delete:
|
|
57
|
+
label_name_in_gl = label_to_delete.name
|
|
58
|
+
|
|
59
|
+
info(f"{label_name_in_gl} not in configured labels")
|
|
60
|
+
# only delete labels when enforce is true, because user's maybe automatically applying labels based
|
|
61
|
+
# on Repo state, for example: Compliance Framework labels based on language or CI-template status
|
|
62
|
+
if enforce:
|
|
63
|
+
info(f"Removing {label_name_in_gl} from {parent_object_type}")
|
|
64
|
+
self.get_label(group_or_project, label_to_delete).delete()
|
|
65
|
+
|
|
66
|
+
# add new labels
|
|
67
|
+
|
|
68
|
+
for label_key in configured_labels.keys():
|
|
69
|
+
if label_key not in existing_label_keys:
|
|
70
|
+
info(f"Creating new label with key: {label_key}, on {parent_object_type}")
|
|
71
|
+
self.create_new_label(
|
|
72
|
+
configured_labels, group_or_project, label_key, parent_object_type, existing_group_and_parent_labels
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def configured_label_matches_gitlab_label(configured_label_name: str, key: str, label_name_in_gl: str):
|
|
77
|
+
return (
|
|
78
|
+
configured_label_name is not None and label_name_in_gl == configured_label_name
|
|
79
|
+
) or label_name_in_gl == key
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def get_label(group_or_project, listed_label) -> GroupLabel | ProjectLabel:
|
|
83
|
+
return group_or_project.labels.get(listed_label.id)
|
|
84
|
+
|
|
85
|
+
@staticmethod
|
|
86
|
+
def update_existing_label(
|
|
87
|
+
configured_label,
|
|
88
|
+
full_label: GroupLabel | ProjectLabel,
|
|
89
|
+
parent_object_type: str,
|
|
90
|
+
):
|
|
91
|
+
info(f"Updating {full_label.name} on {parent_object_type}")
|
|
92
|
+
|
|
93
|
+
# label APIs in python-gitlab do not supply an update() method
|
|
94
|
+
for key in configured_label:
|
|
95
|
+
full_label.__setattr__(key, configured_label[key])
|
|
96
|
+
|
|
97
|
+
full_label.save()
|
|
98
|
+
|
|
99
|
+
def create_new_label(
|
|
100
|
+
self,
|
|
101
|
+
configured_labels,
|
|
102
|
+
group_or_project: Group | Project,
|
|
103
|
+
label_key: str,
|
|
104
|
+
parent_object_type: str,
|
|
105
|
+
existing_group_and_parent_labels: Union[List[GroupLabel], List[ProjectLabel]],
|
|
106
|
+
):
|
|
107
|
+
label = configured_labels.get(label_key)
|
|
108
|
+
configured_label_name = label.get("name")
|
|
109
|
+
found_existing_label = False
|
|
110
|
+
for existing_label in existing_group_and_parent_labels:
|
|
111
|
+
if self.configured_label_matches_gitlab_label(configured_label_name, label_key, existing_label.name):
|
|
112
|
+
warning(
|
|
113
|
+
f"Label {existing_label.name} already exists either in {group_or_project.name} or on Parent Group, so will not create"
|
|
114
|
+
)
|
|
115
|
+
found_existing_label = True
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
if not found_existing_label:
|
|
119
|
+
info(f"Adding label with key: {label_key} to {parent_object_type}")
|
|
120
|
+
group_or_project.labels.create({"name": label_key, **label})
|