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,12 @@
|
|
|
1
|
+
from gitlabform.configuration.common import ConfigurationCommon
|
|
2
|
+
from gitlabform.configuration.groups import ConfigurationGroups
|
|
3
|
+
from gitlabform.configuration.projects import ConfigurationProjects
|
|
4
|
+
|
|
5
|
+
#
|
|
6
|
+
# This the only external interface for operating on the configuration from a given YAML file
|
|
7
|
+
# (or an input string in case of tests).
|
|
8
|
+
#
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Configuration(ConfigurationProjects):
|
|
12
|
+
pass
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
|
|
3
|
+
from gitlabform.configuration.core import ConfigurationCore
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ConfigurationCommon(ConfigurationCore, ABC):
|
|
7
|
+
"""
|
|
8
|
+
Gets the common configuration, applied to all groups and projects.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
def get_common_config(self) -> dict:
|
|
12
|
+
"""
|
|
13
|
+
:return: literal common configuration or empty dict if not defined
|
|
14
|
+
"""
|
|
15
|
+
common_config = self.get("projects_and_groups|*", {})
|
|
16
|
+
if common_config:
|
|
17
|
+
section_name = "*"
|
|
18
|
+
self._validate_break_inheritance_flag(common_config, section_name)
|
|
19
|
+
return common_config
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import logging
|
|
6
|
+
import textwrap
|
|
7
|
+
from abc import ABC
|
|
8
|
+
from copy import deepcopy
|
|
9
|
+
from logging import debug
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from ruamel.yaml.scalarstring import ScalarString
|
|
12
|
+
from types import SimpleNamespace
|
|
13
|
+
|
|
14
|
+
from logging import critical, info
|
|
15
|
+
|
|
16
|
+
from mergedeep import merge
|
|
17
|
+
from yamlpath.common import Parsers
|
|
18
|
+
from yamlpath.wrappers import ConsolePrinter
|
|
19
|
+
|
|
20
|
+
from gitlabform.constants import EXIT_INVALID_INPUT
|
|
21
|
+
from ruamel.yaml.comments import CommentedMap
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConfigurationCore(ABC):
|
|
25
|
+
"""
|
|
26
|
+
The functionality shared by various types of config (common, groups, projects)
|
|
27
|
+
is implemented here.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, config_path=None, config_string=None):
|
|
31
|
+
if config_path and config_string:
|
|
32
|
+
critical("Please initialize with either config_path or config_string, not both.")
|
|
33
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
34
|
+
try:
|
|
35
|
+
if config_string:
|
|
36
|
+
self.config = self._parse_yaml(config_string, config_string=True)
|
|
37
|
+
self.config_dir = "."
|
|
38
|
+
else: # maybe config_path
|
|
39
|
+
config_path = self._get_config_path(config_path)
|
|
40
|
+
self.config = self._parse_yaml(config_path, config_string=False)
|
|
41
|
+
self.config_dir = os.path.dirname(config_path)
|
|
42
|
+
|
|
43
|
+
# below checks are only needed in the non-test mode, when the config is read from file
|
|
44
|
+
|
|
45
|
+
if self.config.get("example_config"):
|
|
46
|
+
critical(
|
|
47
|
+
"Example config detected, aborting.\n"
|
|
48
|
+
"Haven't you forgotten to use `-c <config_file>` parameter?\n"
|
|
49
|
+
"If you created your config based on the example config.yml,"
|
|
50
|
+
" then please remove 'example_config' key."
|
|
51
|
+
)
|
|
52
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
53
|
+
|
|
54
|
+
if self.config.get("config_version", 1) != 4:
|
|
55
|
+
critical(
|
|
56
|
+
"This version of GitLabForm requires 'config_version: 4' entry in the config. "
|
|
57
|
+
"This ensures that if the application behavior changes in a backward-incompatible way,"
|
|
58
|
+
" you won't apply unwanted configuration to your GitLab instance.\n"
|
|
59
|
+
"Please follow this guide: https://gitlabform.github.io/gitlabform/upgrade/\n"
|
|
60
|
+
)
|
|
61
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
62
|
+
|
|
63
|
+
self._find_almost_duplicates()
|
|
64
|
+
|
|
65
|
+
# we are NOT checking for the existence of non-empty 'projects_and_groups' key here
|
|
66
|
+
# as it would break using GitLabForm as a library
|
|
67
|
+
|
|
68
|
+
except (FileNotFoundError, OSError):
|
|
69
|
+
raise ConfigFileNotFoundException(config_path)
|
|
70
|
+
|
|
71
|
+
except Exception as e:
|
|
72
|
+
raise ConfigInvalidException(e)
|
|
73
|
+
|
|
74
|
+
@staticmethod
|
|
75
|
+
def _get_config_path(config_path):
|
|
76
|
+
if "APP_HOME" in os.environ:
|
|
77
|
+
# using this env var should be considered unofficial, we need this temporarily
|
|
78
|
+
# for backwards compatibility. support for it may be removed without notice, do not use it!
|
|
79
|
+
config_path = os.path.join(os.environ["APP_HOME"], "config.yml")
|
|
80
|
+
elif not config_path:
|
|
81
|
+
# this case is only meant for using gitlabform as a library
|
|
82
|
+
config_path = os.path.join(str(Path.home()), ".gitlabform", "config.yml")
|
|
83
|
+
elif config_path in [os.path.join(".", "config.yml"), "config.yml"]:
|
|
84
|
+
# provided points to config.yml in the app current working dir
|
|
85
|
+
config_path = os.path.join(os.getcwd(), "config.yml")
|
|
86
|
+
|
|
87
|
+
return config_path
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def _parse_yaml(source: str, config_string: bool):
|
|
91
|
+
logging_args = SimpleNamespace(quiet=False, verbose=False, debug=False)
|
|
92
|
+
log = ConsolePrinter(logging_args)
|
|
93
|
+
|
|
94
|
+
yaml = Parsers.get_yaml_editor()
|
|
95
|
+
|
|
96
|
+
# for better backward compatibility with PyYAML (that supports only YAML 1.1) used in the previous
|
|
97
|
+
# GitLabForm versions, let's force ruamel.yaml to use YAML version 1.1 by default too
|
|
98
|
+
yaml.version = (1, 1)
|
|
99
|
+
|
|
100
|
+
if config_string:
|
|
101
|
+
config = textwrap.dedent(source)
|
|
102
|
+
info("Reading config from the provided string.")
|
|
103
|
+
yaml_data, doc_loaded = Parsers.get_yaml_data(yaml, log, config, literal=True)
|
|
104
|
+
else:
|
|
105
|
+
config_path = source
|
|
106
|
+
info(f"Reading config from file: {config_path}")
|
|
107
|
+
yaml_data, doc_loaded = Parsers.get_yaml_data(yaml, log, config_path)
|
|
108
|
+
|
|
109
|
+
if doc_loaded:
|
|
110
|
+
debug("Config parsed successfully as YAML.")
|
|
111
|
+
else:
|
|
112
|
+
# an error message has already been printed via ConsolePrinter
|
|
113
|
+
exit(EXIT_INVALID_INPUT)
|
|
114
|
+
|
|
115
|
+
return yaml_data
|
|
116
|
+
|
|
117
|
+
def get(self, path, default=None) -> Any:
|
|
118
|
+
"""
|
|
119
|
+
:param path: "path" to given element in YAML file, for example for:
|
|
120
|
+
|
|
121
|
+
group_settings:
|
|
122
|
+
sddc:
|
|
123
|
+
deploy_keys:
|
|
124
|
+
qa_puppet:
|
|
125
|
+
key: some key...
|
|
126
|
+
title: some title...
|
|
127
|
+
can_push: false
|
|
128
|
+
|
|
129
|
+
..a path to a single element array ['qa_puppet'] will be: "group_settings|sddc|deploy_keys".
|
|
130
|
+
|
|
131
|
+
To get the dict under it use: get("group_settings|sddc|deploy_keys")
|
|
132
|
+
|
|
133
|
+
:param default: the value to return if the key is not found. The default 'None' means that an exception
|
|
134
|
+
will be raised in such case.
|
|
135
|
+
:return: element from YAML file (dict, array, string...)
|
|
136
|
+
"""
|
|
137
|
+
tokens = path.split("|")
|
|
138
|
+
current = self.config
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
for token in tokens:
|
|
142
|
+
current = current[token]
|
|
143
|
+
|
|
144
|
+
if isinstance(current, ScalarString):
|
|
145
|
+
to_return = str(current)
|
|
146
|
+
else:
|
|
147
|
+
to_return = current
|
|
148
|
+
except:
|
|
149
|
+
if default is not None:
|
|
150
|
+
to_return = default
|
|
151
|
+
else:
|
|
152
|
+
raise KeyNotFoundException(path) from None
|
|
153
|
+
|
|
154
|
+
return to_return
|
|
155
|
+
|
|
156
|
+
@staticmethod
|
|
157
|
+
def _validate_break_inheritance_flag(config: dict, section_name: str, parent_key: str = "") -> None:
|
|
158
|
+
for key, value in config.items():
|
|
159
|
+
if "inherit" == key:
|
|
160
|
+
parent_key_description = ' under key "' + parent_key + '"' if parent_key else ""
|
|
161
|
+
critical(
|
|
162
|
+
f'The inheritance-break flag set in "{section_name}"{parent_key_description} is invalid\n'
|
|
163
|
+
f"because it has no higher level setting to inherit from.\n"
|
|
164
|
+
)
|
|
165
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
166
|
+
elif type(value) in [CommentedMap, dict]:
|
|
167
|
+
ConfigurationCore._validate_break_inheritance_flag(value, section_name, key)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _merge_configs(more_general_config, more_specific_config) -> dict:
|
|
171
|
+
"""
|
|
172
|
+
:return: merge more general config with more specific configs.
|
|
173
|
+
More specific config values take precedence over more general ones.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
more_general_config = deepcopy(more_general_config)
|
|
177
|
+
more_specific_config = deepcopy(more_specific_config)
|
|
178
|
+
|
|
179
|
+
merged_dict = merge({}, more_general_config, more_specific_config)
|
|
180
|
+
|
|
181
|
+
def break_inheritance(specific_config, parent_path=()):
|
|
182
|
+
"""
|
|
183
|
+
Walk the specific config tree and replace only the exact nested section
|
|
184
|
+
that declares ``inherit: false``.
|
|
185
|
+
|
|
186
|
+
``parent_path`` stores the full key path to the current section because
|
|
187
|
+
the same section name, like ``variables``, can appear in multiple branches.
|
|
188
|
+
Replacing by section name alone can therefore update the wrong subtree.
|
|
189
|
+
"""
|
|
190
|
+
for key, value in specific_config.items():
|
|
191
|
+
if "inherit" == key:
|
|
192
|
+
if not value:
|
|
193
|
+
replace_config_section(merged_dict, parent_path, specific_config)
|
|
194
|
+
break
|
|
195
|
+
elif value:
|
|
196
|
+
critical(f"Cannot set the inheritance break flag with true\n")
|
|
197
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
198
|
+
elif type(value) in [CommentedMap, dict]:
|
|
199
|
+
break_inheritance(value, parent_path + (key,))
|
|
200
|
+
|
|
201
|
+
def replace_config_section(merged_config, parent_path, specific_config):
|
|
202
|
+
"""
|
|
203
|
+
Replace the merged section at ``parent_path`` with the specific section
|
|
204
|
+
that requested an inheritance break, dropping the control flag itself.
|
|
205
|
+
"""
|
|
206
|
+
target_config = merged_config
|
|
207
|
+
for key in parent_path[:-1]:
|
|
208
|
+
target_config = target_config[key]
|
|
209
|
+
|
|
210
|
+
replacement_config = deepcopy(specific_config)
|
|
211
|
+
replacement_config.pop("inherit", None)
|
|
212
|
+
target_config[parent_path[-1]] = replacement_config
|
|
213
|
+
|
|
214
|
+
break_inheritance(more_specific_config)
|
|
215
|
+
|
|
216
|
+
return dict(merged_dict)
|
|
217
|
+
|
|
218
|
+
@staticmethod
|
|
219
|
+
def _get_case_insensitively(a_dict: dict, a_key: str):
|
|
220
|
+
for dict_key in a_dict.keys():
|
|
221
|
+
if dict_key.lower() == a_key.lower():
|
|
222
|
+
return a_dict[dict_key]
|
|
223
|
+
return {}
|
|
224
|
+
|
|
225
|
+
@staticmethod
|
|
226
|
+
def _is_skipped_case_insensitively(an_array: list, item: str) -> bool:
|
|
227
|
+
"""
|
|
228
|
+
:return: if item is defined in the list to be skipped
|
|
229
|
+
"""
|
|
230
|
+
item = item.lower()
|
|
231
|
+
|
|
232
|
+
for list_element in an_array:
|
|
233
|
+
list_element = list_element.lower()
|
|
234
|
+
|
|
235
|
+
if list_element == item:
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
if (
|
|
239
|
+
list_element.endswith("/*")
|
|
240
|
+
and item.startswith(list_element[:-2])
|
|
241
|
+
and len(item) >= len(list_element[:-2])
|
|
242
|
+
):
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
return False
|
|
246
|
+
|
|
247
|
+
def _find_almost_duplicates(self):
|
|
248
|
+
# in GitLab groups and projects names are de facto case insensitive:
|
|
249
|
+
# you can change the case of both name and path BUT you cannot create
|
|
250
|
+
# 2 groups which names differ only with case and the same thing for
|
|
251
|
+
# projects. therefore we cannot allow such entries in the config,
|
|
252
|
+
# as they would be ambiguous.
|
|
253
|
+
|
|
254
|
+
for path in [
|
|
255
|
+
"projects_and_groups",
|
|
256
|
+
"skip_groups",
|
|
257
|
+
"skip_projects",
|
|
258
|
+
]:
|
|
259
|
+
if self.get(path, 0):
|
|
260
|
+
almost_duplicates = self._find_almost_duplicates_in(path)
|
|
261
|
+
if almost_duplicates:
|
|
262
|
+
critical(
|
|
263
|
+
f"There are almost duplicates in the keys of {path} - they differ only in case.\n"
|
|
264
|
+
f"They are: {', '.join(almost_duplicates)}\n"
|
|
265
|
+
f"This is not allowed as we ignore the case for group and project names."
|
|
266
|
+
)
|
|
267
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
268
|
+
|
|
269
|
+
def _find_almost_duplicates_in(self, configuration_path):
|
|
270
|
+
"""
|
|
271
|
+
Checks given configuration key and reads its keys - if it is a dict - or elements - if it is a list.
|
|
272
|
+
Looks for items that are almost the same - they differ only in the case.
|
|
273
|
+
:param configuration_path: configuration path, f.e. "group_settings"
|
|
274
|
+
:return: list of items that have almost duplicates,
|
|
275
|
+
or an empty list if none are found
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
dict_or_list = self.get(configuration_path)
|
|
279
|
+
if isinstance(dict_or_list, dict):
|
|
280
|
+
items = dict_or_list.keys()
|
|
281
|
+
else:
|
|
282
|
+
items = dict_or_list
|
|
283
|
+
|
|
284
|
+
items_with_lowercase_names = [x.lower() for x in items]
|
|
285
|
+
|
|
286
|
+
# casting these to sets will deduplicate the one with lowercase names
|
|
287
|
+
# lowering its cardinality if there were elements in it
|
|
288
|
+
# that before lowering differed only in case
|
|
289
|
+
if len(set(items)) != len(set(items_with_lowercase_names)):
|
|
290
|
+
# we have some almost duplicates, let's find them
|
|
291
|
+
almost_duplicates = []
|
|
292
|
+
for first_item in items:
|
|
293
|
+
occurrences = 0
|
|
294
|
+
for second_item in items_with_lowercase_names:
|
|
295
|
+
if first_item.lower() == second_item.lower():
|
|
296
|
+
occurrences += 1
|
|
297
|
+
if occurrences == 2:
|
|
298
|
+
almost_duplicates.append(first_item)
|
|
299
|
+
break
|
|
300
|
+
return almost_duplicates
|
|
301
|
+
|
|
302
|
+
else:
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class ConfigFileNotFoundException(Exception):
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class ConfigInvalidException(Exception):
|
|
311
|
+
def __init__(self, underlying: Exception):
|
|
312
|
+
self.underlying = underlying
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class KeyNotFoundException(Exception):
|
|
316
|
+
__slots__ = "key"
|
|
317
|
+
|
|
318
|
+
def __init__(self, key: str):
|
|
319
|
+
if logging.getLogger().getEffectiveLevel() <= logging.DEBUG:
|
|
320
|
+
self.key = key
|
|
321
|
+
else:
|
|
322
|
+
critical(f"Unable to find the key: {key.replace('|', '.')}\n")
|
|
323
|
+
sys.exit(EXIT_INVALID_INPUT)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from logging import debug
|
|
5
|
+
|
|
6
|
+
from gitlabform.configuration import ConfigurationCommon
|
|
7
|
+
from gitlabform.util import to_str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationGroups(ConfigurationCommon, ABC):
|
|
11
|
+
"""
|
|
12
|
+
Gets the groups, skipped groups and their effective configuration.
|
|
13
|
+
|
|
14
|
+
ConfigurationCommon is an ancestor of this class because the effective group configuration
|
|
15
|
+
depends on the common configuration.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def get_groups(self) -> list:
|
|
19
|
+
"""
|
|
20
|
+
:return: sorted list of groups that are EXPLICITLY defined in the config
|
|
21
|
+
"""
|
|
22
|
+
groups = []
|
|
23
|
+
projects_and_groups = self.get("projects_and_groups")
|
|
24
|
+
for element in projects_and_groups.keys():
|
|
25
|
+
if element.endswith("/*"):
|
|
26
|
+
# cut off that "/*"
|
|
27
|
+
group_name = element[:-2]
|
|
28
|
+
groups.append(group_name)
|
|
29
|
+
return sorted(groups)
|
|
30
|
+
|
|
31
|
+
def is_group_skipped(self, group):
|
|
32
|
+
"""
|
|
33
|
+
:return: if group is defined in the key with groups to skip,
|
|
34
|
+
ignoring the case
|
|
35
|
+
"""
|
|
36
|
+
return self._is_skipped_case_insensitively(self.get("skip_groups", []), group)
|
|
37
|
+
|
|
38
|
+
@functools.lru_cache()
|
|
39
|
+
def get_effective_config_for_group(self, group) -> dict:
|
|
40
|
+
"""
|
|
41
|
+
:param group: "group_name"
|
|
42
|
+
:return: merged configuration for this group, from common, group. Merging is additive.
|
|
43
|
+
"""
|
|
44
|
+
# Check if group must be skipped
|
|
45
|
+
if self.is_group_skipped(group):
|
|
46
|
+
debug(f"Group {group} is skipped, returning empty config")
|
|
47
|
+
return {}
|
|
48
|
+
|
|
49
|
+
common_config = self.get_common_config()
|
|
50
|
+
debug("*Effective* common config: %s", to_str(common_config))
|
|
51
|
+
|
|
52
|
+
if "/" in group:
|
|
53
|
+
group_config = self._get_effective_subgroup_config(group)
|
|
54
|
+
else:
|
|
55
|
+
group_config = self._get_group_config(group)
|
|
56
|
+
debug("*Effective* group/subgroup config: %s", to_str(group_config))
|
|
57
|
+
|
|
58
|
+
if not common_config and group_config:
|
|
59
|
+
self._validate_break_inheritance_flag(group_config, group)
|
|
60
|
+
|
|
61
|
+
effective_config_for_group = self._merge_configs(common_config, group_config)
|
|
62
|
+
debug(
|
|
63
|
+
"*Effective* config common+group/subgroup: %s",
|
|
64
|
+
to_str(effective_config_for_group),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return effective_config_for_group
|
|
68
|
+
|
|
69
|
+
def _get_effective_subgroup_config(self, subgroup):
|
|
70
|
+
#
|
|
71
|
+
# Goes through a subgroups hierarchy, from top to bottom
|
|
72
|
+
#
|
|
73
|
+
# "x/y/x" -> ["x", "x/y", "x/y/z"]
|
|
74
|
+
#
|
|
75
|
+
# ...and for each element after 1st generate effective config from previous effective one merged with current:
|
|
76
|
+
#
|
|
77
|
+
# | v |
|
|
78
|
+
# \---> a |
|
|
79
|
+
# | v
|
|
80
|
+
# \------>b = effective config to return
|
|
81
|
+
#
|
|
82
|
+
# ...where a = merged_config("x", "x/y") and b = merged_config(a, "x/y/z")
|
|
83
|
+
#
|
|
84
|
+
|
|
85
|
+
effective_config = {}
|
|
86
|
+
elements = subgroup.split("/")
|
|
87
|
+
last_element = None
|
|
88
|
+
for element in elements:
|
|
89
|
+
if not last_element:
|
|
90
|
+
effective_config = self._get_group_config(element)
|
|
91
|
+
debug("First level config for '%s': %s", element, to_str(effective_config))
|
|
92
|
+
last_element = element
|
|
93
|
+
else:
|
|
94
|
+
next_level_subgroup = last_element + "/" + element
|
|
95
|
+
next_level_subgroup_config = self._get_group_config(next_level_subgroup)
|
|
96
|
+
debug(
|
|
97
|
+
"Config for '%s': %s",
|
|
98
|
+
next_level_subgroup,
|
|
99
|
+
to_str(next_level_subgroup_config),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if effective_config:
|
|
103
|
+
self._validate_break_inheritance_flag(effective_config, subgroup)
|
|
104
|
+
elif not effective_config and next_level_subgroup_config:
|
|
105
|
+
self._validate_break_inheritance_flag(next_level_subgroup_config, next_level_subgroup)
|
|
106
|
+
|
|
107
|
+
effective_config = self._merge_configs(
|
|
108
|
+
effective_config,
|
|
109
|
+
next_level_subgroup_config,
|
|
110
|
+
)
|
|
111
|
+
debug(
|
|
112
|
+
"Merged previous level config for '%s' with config for '%s': %s",
|
|
113
|
+
last_element,
|
|
114
|
+
next_level_subgroup,
|
|
115
|
+
to_str(effective_config),
|
|
116
|
+
)
|
|
117
|
+
last_element = last_element + "/" + element
|
|
118
|
+
|
|
119
|
+
return effective_config
|
|
120
|
+
|
|
121
|
+
def _get_group_config(self, group) -> dict:
|
|
122
|
+
"""
|
|
123
|
+
:param group: group/subgroup
|
|
124
|
+
:return: configuration for this group/subgroup or empty dict if not defined,
|
|
125
|
+
ignoring the case
|
|
126
|
+
"""
|
|
127
|
+
return self._get_case_insensitively(self.get("projects_and_groups"), f"{group}/*")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from abc import ABC
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
from logging import debug
|
|
5
|
+
|
|
6
|
+
from gitlabform.configuration import ConfigurationGroups
|
|
7
|
+
from gitlabform.util import to_str
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationProjects(ConfigurationGroups, ABC):
|
|
11
|
+
"""
|
|
12
|
+
Gets the projects, skipped projects and their effective configuration.
|
|
13
|
+
|
|
14
|
+
ConfigurationGroups is an ancestor of this class because the effective project configuration
|
|
15
|
+
depends on the groups (and common) configuration.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def get_projects(self) -> list:
|
|
19
|
+
"""
|
|
20
|
+
:return: sorted list of projects names, that are EXPLICITLY defined in the config
|
|
21
|
+
"""
|
|
22
|
+
projects = []
|
|
23
|
+
projects_and_groups = self.get("projects_and_groups")
|
|
24
|
+
for element in projects_and_groups.keys():
|
|
25
|
+
if element != "*" and not element.endswith("/*"):
|
|
26
|
+
projects.append(element)
|
|
27
|
+
return sorted(projects)
|
|
28
|
+
|
|
29
|
+
def is_project_skipped(self, project) -> bool:
|
|
30
|
+
"""
|
|
31
|
+
:return: if project is defined in the key with projects to skip,
|
|
32
|
+
ignoring the case
|
|
33
|
+
"""
|
|
34
|
+
return self._is_skipped_case_insensitively(self.get("skip_projects", []), project)
|
|
35
|
+
|
|
36
|
+
@functools.lru_cache()
|
|
37
|
+
def get_effective_config_for_project(self, group_and_project) -> dict:
|
|
38
|
+
"""
|
|
39
|
+
:param group_and_project: "project_group/project_name"
|
|
40
|
+
:return: merged configuration for this project, from common, group/subgroup and project level.
|
|
41
|
+
If project belongs to a subgroup, like "x/y/z", then it gets config from both group "x" as well
|
|
42
|
+
as subgroup "y".
|
|
43
|
+
Merging is additive.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
group, _ = group_and_project.rsplit("/", 1)
|
|
47
|
+
|
|
48
|
+
effective_config_for_group = self.get_effective_config_for_group(group)
|
|
49
|
+
|
|
50
|
+
project_config = self._get_project_config(group_and_project)
|
|
51
|
+
debug("Project config: %s", to_str(project_config))
|
|
52
|
+
|
|
53
|
+
if not effective_config_for_group and project_config:
|
|
54
|
+
self._validate_break_inheritance_flag(project_config, group_and_project)
|
|
55
|
+
|
|
56
|
+
effective_config_for_project = self._merge_configs(
|
|
57
|
+
effective_config_for_group,
|
|
58
|
+
project_config,
|
|
59
|
+
)
|
|
60
|
+
debug(
|
|
61
|
+
"*Effective* config common+group/subgroup+project: %s",
|
|
62
|
+
to_str(effective_config_for_project),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return effective_config_for_project
|
|
66
|
+
|
|
67
|
+
def _get_project_config(self, group_and_project) -> dict:
|
|
68
|
+
"""
|
|
69
|
+
:param group_and_project: 'group/project'
|
|
70
|
+
:return: configuration for this project or empty dict if not defined,
|
|
71
|
+
ignoring the case
|
|
72
|
+
"""
|
|
73
|
+
return self._get_case_insensitively(self.get("projects_and_groups"), group_and_project)
|