forked from baloise/gitopscli
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgitops_config.py
More file actions
443 lines (389 loc) · 22.2 KB
/
Copy pathgitops_config.py
File metadata and controls
443 lines (389 loc) · 22.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
import hashlib
import re
from collections.abc import Callable
from dataclasses import dataclass
from string import Template
from typing import Any, ClassVar
from gitopscli.gitops_exception import GitOpsException
_VARIABLE_REGEX = re.compile(r"\${(\w+)}")
@dataclass(frozen=True)
class GitOpsConfig:
class Replacement:
@dataclass(frozen=True)
class PreviewContext:
gitops_config: "GitOpsConfig"
preview_id: str
git_hash: str
__VARIABLE_MAPPERS: ClassVar[dict[str, Callable[["GitOpsConfig.Replacement.PreviewContext"], str]]] = {
"GIT_HASH": lambda context: context.git_hash,
"PREVIEW_HOST": lambda context: context.gitops_config.get_preview_host(context.preview_id),
"PREVIEW_NAMESPACE": lambda context: context.gitops_config.get_preview_namespace(context.preview_id),
"APPLICATION_NAME": lambda context: context.gitops_config.application_name,
"PREVIEW_ID": lambda context: context.preview_id,
"PREVIEW_ID_HASH": lambda context: GitOpsConfig.create_preview_id_hash(context.preview_id),
"PREVIEW_ID_HASH_SHORT": lambda context: GitOpsConfig.create_preview_id_hash_short(context.preview_id),
}
def __init__(self, path: str, value_template: str) -> None:
assert isinstance(path, str), "path of wrong type!"
assert isinstance(value_template, str), "value_template of wrong type!"
self.path = path
self.value_template = value_template
for var in _VARIABLE_REGEX.findall(self.value_template):
variables = self.__VARIABLE_MAPPERS.keys()
if var not in variables:
raise GitOpsException(
f"Replacement value '{self.value_template}' for path '{self.path}' "
f"contains invalid variable: {var}",
)
def get_value(self, context: PreviewContext) -> str:
val = self.value_template
for variable, value_func in self.__VARIABLE_MAPPERS.items():
val = val.replace(f"${{{variable}}}", value_func(context))
return val
api_version: int
application_name: str
preview_host_template: str
messages_created_template: str
messages_updated_template: str
messages_uptodate_template: str
preview_template_organisation: str
preview_template_repository: str
preview_template_path_template: str
preview_template_branch: str | None
preview_target_organisation: str
preview_target_repository: str
preview_target_branch: str | None
preview_target_namespace_template: str
preview_target_max_namespace_length: int
replacements: dict[str, list[Replacement]]
@property
def preview_template_path(self) -> str:
return self.preview_template_path_template.replace("${APPLICATION_NAME}", self.application_name)
def __post_init__(self) -> None:
assert isinstance(self.application_name, str), "application_name of wrong type!"
assert isinstance(self.preview_host_template, str), "preview_host_template of wrong type!"
self.__assert_variables(
self.preview_host_template,
{"APPLICATION_NAME", "PREVIEW_ID_HASH", "PREVIEW_ID_HASH_SHORT", "PREVIEW_ID", "PREVIEW_NAMESPACE"},
)
assert isinstance(self.preview_template_organisation, str), "preview_template_organisation of wrong type!"
assert isinstance(self.preview_template_repository, str), "preview_template_repository of wrong type!"
assert isinstance(self.preview_template_path_template, str), "preview_template_path_template of wrong type!"
self.__assert_variables(self.preview_template_path_template, {"APPLICATION_NAME"})
if self.preview_template_branch is not None:
assert isinstance(self.preview_template_branch, str), "preview_template_branch of wrong type!"
assert isinstance(self.preview_target_organisation, str), "preview_target_organisation of wrong type!"
assert isinstance(self.preview_target_repository, str), "preview_target_repository of wrong type!"
if self.preview_target_branch is not None:
assert isinstance(self.preview_target_branch, str), "preview_target_branch of wrong type!"
assert isinstance(
self.preview_target_namespace_template,
str,
), "preview_target_namespace_template of wrong type!"
self.__assert_variables(
self.preview_target_namespace_template,
{"APPLICATION_NAME", "PREVIEW_ID_HASH", "PREVIEW_ID_HASH_SHORT", "PREVIEW_ID"},
)
assert isinstance(
self.preview_target_max_namespace_length,
int,
), "preview_target_max_namespace_length of wrong type!"
assert self.preview_target_max_namespace_length >= 1, "preview_target_max_namespace_length is < 1!"
assert isinstance(self.replacements, dict), "replacements of wrong type!"
for file, replacements in self.replacements.items():
assert isinstance(file, str), f"replacement file '{file}' of wrong type!"
for index, replacement in enumerate(replacements):
assert isinstance(replacement, self.Replacement), f"replacement[{file}][{index}] of wrong type!"
def get_preview_host(self, preview_id: str) -> str:
preview_host = self.preview_host_template
preview_host = preview_host.replace("${APPLICATION_NAME}", self.application_name)
preview_host = preview_host.replace("${PREVIEW_ID_HASH}", self.create_preview_id_hash(preview_id))
preview_host = preview_host.replace("${PREVIEW_ID_HASH_SHORT}", self.create_preview_id_hash_short(preview_id))
preview_host = preview_host.replace("${PREVIEW_ID}", self.__sanitize(preview_id))
return preview_host.replace("${PREVIEW_NAMESPACE}", self.get_preview_namespace(preview_id))
def get_preview_namespace(self, preview_id: str) -> str:
preview_namespace = self.preview_target_namespace_template
preview_namespace = preview_namespace.replace("${APPLICATION_NAME}", self.application_name)
preview_namespace = preview_namespace.replace("${PREVIEW_ID_HASH}", self.create_preview_id_hash(preview_id))
preview_namespace = preview_namespace.replace(
"${PREVIEW_ID_HASH_SHORT}",
self.create_preview_id_hash_short(preview_id),
)
current_length = len(preview_namespace) - len("${PREVIEW_ID}")
remaining_length = self.preview_target_max_namespace_length - current_length
if remaining_length < 1:
preview_namespace = preview_namespace.replace("${PREVIEW_ID}", "")
raise GitOpsException(
f"Preview namespace is too long (max {self.preview_target_max_namespace_length} chars): "
f"{preview_namespace} ({len(preview_namespace)} chars)",
)
sanitized_preview_id = self.__sanitize(preview_id, remaining_length)
preview_namespace = preview_namespace.replace("${PREVIEW_ID}", sanitized_preview_id)
preview_namespace = preview_namespace.lower()
invalid_character = re.search(r"[^a-z0-9-]", preview_namespace)
if invalid_character:
raise GitOpsException(f"Invalid character in preview namespace: '{invalid_character[0]}'")
return preview_namespace
def get_created_message(self, context: Replacement.PreviewContext) -> str:
return self.fill_template(self.messages_created_template, context)
def get_updated_message(self, context: Replacement.PreviewContext) -> str:
return self.fill_template(self.messages_updated_template, context)
def get_uptodate_message(self, context: Replacement.PreviewContext) -> str:
return self.fill_template(self.messages_uptodate_template, context)
def fill_template(self, template: str, context: Replacement.PreviewContext) -> str:
return Template(template).substitute(
APPLICATION_NAME=self.application_name,
PREVIEW_ID_HASH=self.create_preview_id_hash(context.preview_id),
PREVIEW_ID_HASH_SHORT=self.create_preview_id_hash_short(context.preview_id),
PREVIEW_ID=self.__sanitize(context.preview_id),
PREVIEW_NAMESPACE=self.get_preview_namespace(context.preview_id),
PREVIEW_HOST=self.get_preview_host(context.preview_id),
GIT_HASH=context.git_hash,
)
@staticmethod
def __assert_variables(template: str, variables: set[str]) -> None:
for var in _VARIABLE_REGEX.findall(template):
if var not in variables:
raise GitOpsException(f"GitOps config template '{template}' contains invalid variable: {var}")
@staticmethod
def __sanitize(preview_id: str, max_length: int | None = None) -> str:
sanitized_preview_id = preview_id.lower()
sanitized_preview_id = re.sub(r"[^a-z0-9-]", "-", sanitized_preview_id)
sanitized_preview_id = re.sub(r"-+", "-", sanitized_preview_id)
if max_length is not None:
sanitized_preview_id = sanitized_preview_id[0:max_length]
return re.sub(r"-$", "", sanitized_preview_id)
def is_preview_template_equal_target(self) -> bool:
return (
self.preview_template_organisation == self.preview_target_organisation
and self.preview_template_repository == self.preview_target_repository
and self.preview_template_branch == self.preview_target_branch
)
@staticmethod
def create_preview_id_hash(preview_id: str) -> str:
return hashlib.sha256(preview_id.encode("utf-8")).hexdigest()[:8]
@staticmethod
def create_preview_id_hash_short(preview_id: str) -> str:
return GitOpsConfig.create_preview_id_hash(preview_id)[:3]
@staticmethod
def from_yaml(yaml: Any) -> "GitOpsConfig":
return _GitOpsConfigYamlParser(yaml).parse()
class _GitOpsConfigYamlParser:
def __init__(self, yaml: Any) -> None:
self.__yaml = yaml
def __get_value(self, key: str) -> Any:
keys = key.split(".")
data = self.__yaml
for k in keys:
if not isinstance(data, dict) or k not in data:
raise GitOpsException(f"Key '{key}' not found in GitOps config!")
data = data[k]
return data
def __get_value_or_default(self, key: str, default: Any | None) -> Any | None:
try:
return self.__get_value(key)
except GitOpsException:
return default
def __get_string_value(self, key: str) -> str:
value = self.__get_value(key)
if not isinstance(value, str):
raise GitOpsException(f"Item '{key}' should be a string in GitOps config!")
return value
def __get_string_value_or_default(self, key: str, default: str) -> str:
value = self.__get_value_or_default(key, default)
if not isinstance(value, str):
raise GitOpsException(f"Item '{key}' should be a string in GitOps config!")
return value
def __get_string_value_or_none(self, key: str) -> str | None:
value = self.__get_value_or_default(key, None)
if not isinstance(value, str) and value is not None:
raise GitOpsException(f"Item '{key}' should be a string in GitOps config!")
return value
def __get_int_value_or_default(self, key: str, default: int) -> int:
value = self.__get_value_or_default(key, default)
if not isinstance(value, int):
raise GitOpsException(f"Item '{key}' should be an integer in GitOps config!")
return value
def __get_list_value(self, key: str) -> list[Any]:
value = self.__get_value(key)
if not isinstance(value, list):
raise GitOpsException(f"Item '{key}' should be a list in GitOps config!")
return value
def __get_dict_value(self, key: str) -> dict[str, Any]:
value = self.__get_value(key)
if not isinstance(value, dict):
raise GitOpsException(f"Item '{key}' should be an object in GitOps config!")
return value
def parse(self) -> GitOpsConfig:
api_version = self.__get_string_value_or_default("apiVersion", "v0")
if api_version == "v0":
return self.__parse_v0()
if api_version == "v1":
return self.__parse_v1()
if api_version == "v2":
return self.__parse_v2()
raise GitOpsException(f"GitOps config apiVersion '{api_version}' is not supported!")
def __parse_v0(self) -> GitOpsConfig:
replacements: dict[str, list[GitOpsConfig.Replacement]] = {
"Chart.yaml": [GitOpsConfig.Replacement("name", "${PREVIEW_NAMESPACE}")],
"values.yaml": [],
}
replacement_dicts = self.__get_list_value("previewConfig.replace")
for index, replacement_dict in enumerate(replacement_dicts):
if not isinstance(replacement_dict, dict):
raise GitOpsException(f"Item 'previewConfig.replace.[{index}]' should be an object in GitOps config!")
if "path" not in replacement_dict:
raise GitOpsException(f"Key 'previewConfig.replace.[{index}].path' not found in GitOps config!")
if "variable" not in replacement_dict:
raise GitOpsException(f"Key 'previewConfig.replace.[{index}].variable' not found in GitOps config!")
path = replacement_dict["path"]
variable = replacement_dict["variable"]
if not isinstance(path, str):
raise GitOpsException(
f"Item 'previewConfig.replace.[{index}].path' should be a string in GitOps config!",
)
if not isinstance(variable, str):
raise GitOpsException(
f"Item 'previewConfig.replace.[{index}].variable' should be a string in GitOps config!",
)
if "{" in variable or "}" in variable:
raise GitOpsException(f"Item 'previewConfig.replace.[{index}].variable' must not contain '{{' or '}}'!")
if variable == "ROUTE_HOST":
variable = "PREVIEW_HOST" # backwards compatability
if variable == "GIT_COMMIT":
variable = "GIT_HASH" # backwards compatability
replacements["values.yaml"].append(GitOpsConfig.Replacement(path, f"${{{variable}}}"))
preview_target_organisation = self.__get_string_value("deploymentConfig.org")
preview_target_repository = self.__get_string_value("deploymentConfig.repository")
return GitOpsConfig(
api_version=0,
application_name=self.__get_string_value("deploymentConfig.applicationName"),
messages_created_template="New preview environment created for version `${GIT_HASH}`. "
"Access it here: https://${PREVIEW_HOST}",
messages_updated_template="Preview environment updated to version `${GIT_HASH}`. "
"Access it here: https://${PREVIEW_HOST}",
messages_uptodate_template="The version `${GIT_HASH}` has already been deployed. "
"Access it here: https://${PREVIEW_HOST}",
preview_host_template=self.__get_string_value("previewConfig.route.host.template").replace(
"{SHA256_8CHAR_BRANCH_HASH}",
"${PREVIEW_ID_HASH}", # backwards compatibility
),
preview_template_organisation=preview_target_organisation,
preview_template_repository=preview_target_repository,
preview_template_path_template=".preview-templates/${APPLICATION_NAME}",
preview_template_branch=None, # use default branch
preview_target_organisation=preview_target_organisation,
preview_target_repository=preview_target_repository,
preview_target_branch=None, # use default branch
preview_target_namespace_template="${APPLICATION_NAME}-${PREVIEW_ID_HASH}-preview",
preview_target_max_namespace_length=63,
replacements=replacements,
)
def __parse_v1(self) -> GitOpsConfig:
config = self.__parse_v2()
# add $ in front of variables for backwards compatability (e.g. ${FOO}):
def add_var_dollar(template: str) -> str:
return re.sub("(^|[^\\$])({(\\w+)})", "\\1$\\2", template)
replacements: dict[str, list[GitOpsConfig.Replacement]] = {}
for filename, file_replacements in config.replacements.items():
replacements[filename] = [
GitOpsConfig.Replacement(r.path, add_var_dollar(r.value_template)) for r in file_replacements
]
return GitOpsConfig(
api_version=1,
application_name=config.application_name,
messages_created_template="New preview environment created for version `${GIT_HASH}`. "
"Access it here: https://${PREVIEW_HOST}",
messages_updated_template="Preview environment updated to version `${GIT_HASH}`. "
"Access it here: https://${PREVIEW_HOST}",
messages_uptodate_template="The version `${GIT_HASH}` has already been deployed. "
"Access it here: https://${PREVIEW_HOST}",
preview_host_template=add_var_dollar(config.preview_host_template),
preview_template_organisation=config.preview_template_organisation,
preview_template_repository=config.preview_template_repository,
preview_template_path_template=add_var_dollar(config.preview_template_path_template),
preview_template_branch=config.preview_template_branch,
preview_target_organisation=config.preview_target_organisation,
preview_target_repository=config.preview_target_repository,
preview_target_branch=config.preview_target_branch,
preview_target_namespace_template=add_var_dollar(
self.__get_string_value_or_default(
"previewConfig.target.namespace",
"{APPLICATION_NAME}-{PREVIEW_ID}-{PREVIEW_ID_HASH}-preview",
),
),
preview_target_max_namespace_length=63,
replacements=replacements,
)
def __parse_v2(self) -> GitOpsConfig:
preview_target_organisation = self.__get_string_value("previewConfig.target.organisation")
preview_target_repository = self.__get_string_value("previewConfig.target.repository")
preview_target_branch = self.__get_string_value_or_none("previewConfig.target.branch")
preview_target_max_namespace_length = self.__get_int_value_or_default(
"previewConfig.target.maxNamespaceLength",
53,
)
if preview_target_max_namespace_length < 1:
raise GitOpsException("Value 'maxNamespaceLength' should be at least 1 in GitOps config!")
replacements: dict[str, list[GitOpsConfig.Replacement]] = {}
for filename, file_replacements in self.__get_dict_value("previewConfig.replace").items():
replacements[filename] = []
escaped_filename = filename.replace(".", "\\.")
file_key = f"previewConfig.replace.{escaped_filename}"
if not isinstance(file_replacements, list):
raise GitOpsException(f"Item '{file_key}' should be a list in GitOps config!")
for index, file_replacement in enumerate(file_replacements):
key = f"{file_key}.[{index}]"
if not isinstance(file_replacement, dict):
raise GitOpsException(f"Item '{key}' should be an object in GitOps config!")
if "path" not in file_replacement:
raise GitOpsException(f"Key '{key}.path' not found in GitOps config!")
if "value" not in file_replacement:
raise GitOpsException(f"Key '{key}.value' not found in GitOps config!")
path = file_replacement["path"]
value = file_replacement["value"]
if not isinstance(path, str):
raise GitOpsException(f"Item '{key}.path' should be a string in GitOps config!")
if not isinstance(value, str):
raise GitOpsException(f"Item '{key}.value' should be a string in GitOps config!")
replacements[filename].append(GitOpsConfig.Replacement(path, value))
return GitOpsConfig(
api_version=2,
application_name=self.__get_string_value("applicationName"),
messages_created_template=self.__get_string_value_or_default(
"messages.previewEnvCreated",
"New preview environment created for version `${GIT_HASH}`. Access it here: https://${PREVIEW_HOST}",
),
messages_updated_template=self.__get_string_value_or_default(
"messages.previewEnvUpdated",
"Preview environment updated to version `${GIT_HASH}`. Access it here: https://${PREVIEW_HOST}",
),
messages_uptodate_template=self.__get_string_value_or_default(
"messages.previewEnvAlreadyUpToDate",
"The version `${GIT_HASH}` has already been deployed. Access it here: https://${PREVIEW_HOST}",
),
preview_host_template=self.__get_string_value("previewConfig.host"),
preview_template_organisation=self.__get_string_value_or_default(
"previewConfig.template.organisation",
preview_target_organisation,
),
preview_template_repository=self.__get_string_value_or_default(
"previewConfig.template.repository",
preview_target_repository,
),
preview_template_path_template=self.__get_string_value_or_default(
"previewConfig.template.path",
".preview-templates/${APPLICATION_NAME}",
),
preview_template_branch=self.__get_string_value_or_none("previewConfig.template.branch")
or preview_target_branch,
preview_target_organisation=preview_target_organisation,
preview_target_repository=preview_target_repository,
preview_target_branch=preview_target_branch,
preview_target_namespace_template=self.__get_string_value_or_default(
"previewConfig.target.namespace",
"${APPLICATION_NAME}-${PREVIEW_ID}-${PREVIEW_ID_HASH_SHORT}-preview",
),
preview_target_max_namespace_length=preview_target_max_namespace_length,
replacements=replacements,
)