sentry-infra-tools 0.0.3__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.
- assistant/__init__.py +0 -0
- assistant/prdocs.py +147 -0
- assistant/test_prdocs.py +88 -0
- config_builder/__init__.py +0 -0
- config_builder/clean_generated.py +31 -0
- config_builder/combined_generator.py +170 -0
- config_builder/generate_topic_data.py +26 -0
- config_builder/json_schema_validator.py +81 -0
- config_builder/loaders.py +48 -0
- config_builder/materialize_all.py +111 -0
- config_builder/materializer.py +66 -0
- config_builder/merger/__init__.py +52 -0
- config_builder/merger/libsonnet.py +27 -0
- config_builder/merger/test_libsonnet.py +22 -0
- config_builder/merger/test_yaml.py +68 -0
- config_builder/merger/yamljson.py +36 -0
- config_builder/test_combined_generator.py +195 -0
- config_builder/test_json_schema_validator.py +226 -0
- config_builder/test_materializer.py +104 -0
- libsentrykube/__init__.py +3 -0
- libsentrykube/bastions.py +104 -0
- libsentrykube/cluster.py +80 -0
- libsentrykube/config.py +129 -0
- libsentrykube/context.py +36 -0
- libsentrykube/customer.py +108 -0
- libsentrykube/datadog.py +75 -0
- libsentrykube/events.py +206 -0
- libsentrykube/ext.py +677 -0
- libsentrykube/gcloud.py +102 -0
- libsentrykube/google_auth.py +33 -0
- libsentrykube/iap.py +124 -0
- libsentrykube/jira.py +147 -0
- libsentrykube/kube.py +592 -0
- libsentrykube/lint.py +188 -0
- libsentrykube/loader.py +15 -0
- libsentrykube/reversemap.py +199 -0
- libsentrykube/service.py +121 -0
- libsentrykube/setup.py +40 -0
- libsentrykube/ssh.py +42 -0
- libsentrykube/tests/__init__.py +0 -0
- libsentrykube/tests/conftest.py +22 -0
- libsentrykube/tests/test_cluster.py +69 -0
- libsentrykube/tests/test_config.py +76 -0
- libsentrykube/tests/test_context.py +33 -0
- libsentrykube/tests/test_customer.py +19 -0
- libsentrykube/tests/test_datadog.py +73 -0
- libsentrykube/tests/test_deep_merge.py +121 -0
- libsentrykube/tests/test_gcloud.py +42 -0
- libsentrykube/tests/test_jira.py +197 -0
- libsentrykube/tests/test_lint.py +136 -0
- libsentrykube/tests/test_reversemap.py +149 -0
- libsentrykube/toolbox.py +48 -0
- libsentrykube/utils.py +376 -0
- libsentrykube/vault.py +55 -0
- pr_approver/__init__.py +0 -0
- pr_approver/approver.py +174 -0
- pr_approver/gh.py +63 -0
- pr_approver/rules.py +77 -0
- pr_approver/test_approver.py +85 -0
- pr_approver/test_rules.py +225 -0
- sentry_infra_tools-0.0.3.data/scripts/sentry-kube-pop +100 -0
- sentry_infra_tools-0.0.3.dist-info/METADATA +80 -0
- sentry_infra_tools-0.0.3.dist-info/RECORD +94 -0
- sentry_infra_tools-0.0.3.dist-info/WHEEL +5 -0
- sentry_infra_tools-0.0.3.dist-info/entry_points.txt +33 -0
- sentry_infra_tools-0.0.3.dist-info/top_level.txt +5 -0
- sentry_kube/__init__.py +0 -0
- sentry_kube/cli/__init__.py +226 -0
- sentry_kube/cli/apply.py +512 -0
- sentry_kube/cli/audit.py +131 -0
- sentry_kube/cli/cluster.py +110 -0
- sentry_kube/cli/connect.py +195 -0
- sentry_kube/cli/datadog.py +59 -0
- sentry_kube/cli/detect_drift.py +80 -0
- sentry_kube/cli/edit_secret.py +97 -0
- sentry_kube/cli/get_context.py +13 -0
- sentry_kube/cli/get_customers.py +13 -0
- sentry_kube/cli/k9s.py +45 -0
- sentry_kube/cli/kafkactl.py +75 -0
- sentry_kube/cli/kubectl.py +95 -0
- sentry_kube/cli/rendervalues.py +34 -0
- sentry_kube/cli/resolve_pvc.py +107 -0
- sentry_kube/cli/restart.py +73 -0
- sentry_kube/cli/run_job.py +269 -0
- sentry_kube/cli/run_pod.py +419 -0
- sentry_kube/cli/scale.py +233 -0
- sentry_kube/cli/scp.py +52 -0
- sentry_kube/cli/ssh.py +33 -0
- sentry_kube/cli/toolbox.py +28 -0
- sentry_kube/cli/tunnel.py +131 -0
- sentry_kube/cli/validate.py +75 -0
- sentry_kube/ext.py +255 -0
- sentry_kube/render_services.py +70 -0
- sentry_kube/validate_services.py +83 -0
assistant/__init__.py
ADDED
|
File without changes
|
assistant/prdocs.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from json import loads
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import MutableMapping, MutableSet, Sequence
|
|
7
|
+
|
|
8
|
+
COMMENT_TEMPLATE = """# Ops Assistant
|
|
9
|
+
|
|
10
|
+
The ops repo is a large monorepo and there are multiple processes
|
|
11
|
+
to deploy its content depending on what is changed.
|
|
12
|
+
This message is here to provide context.
|
|
13
|
+
|
|
14
|
+
{content}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
INSTRUCTION_TEMPLATE = """
|
|
18
|
+
{content}
|
|
19
|
+
|
|
20
|
+
Files changed:
|
|
21
|
+
```
|
|
22
|
+
{files}
|
|
23
|
+
```
|
|
24
|
+
---
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
INSTRUCTIONS_FILE = "deploy_instructions.md"
|
|
28
|
+
INSTRUCTIONS_CONF_FILE = "deploy_instructions.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Instruction:
|
|
33
|
+
paths: MutableSet[Path]
|
|
34
|
+
text: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class InstructionsMessage:
|
|
38
|
+
"""
|
|
39
|
+
Accumulates instructions to add to a comment in a PR.
|
|
40
|
+
|
|
41
|
+
Given a list of paths (touched by the PR) this scans all its
|
|
42
|
+
parent directories looking for instructions on how to deploy
|
|
43
|
+
the change contained in the file.
|
|
44
|
+
|
|
45
|
+
It then merges all instructions into one message.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, root: Path) -> None:
|
|
49
|
+
# The root directory where the repo is.
|
|
50
|
+
self.__root = root.resolve()
|
|
51
|
+
self.__instructions: MutableMapping[Path, Instruction] = {}
|
|
52
|
+
|
|
53
|
+
def __fetch_instruction(self, path: Path, updated_file: Path) -> None:
|
|
54
|
+
"""
|
|
55
|
+
Looks for a `deploy_instructions.md` file to add to the comment.
|
|
56
|
+
|
|
57
|
+
Many directories should share the same instructions (think about
|
|
58
|
+
the k8s services deployable with GoCD). For that there is the
|
|
59
|
+
`deploy_instructions.json` file that contains a reference to a
|
|
60
|
+
markdown instructions file.
|
|
61
|
+
|
|
62
|
+
This file will also be used to provide more sophisticated
|
|
63
|
+
directives on how to compose the message.
|
|
64
|
+
"""
|
|
65
|
+
config_path = path / INSTRUCTIONS_CONF_FILE
|
|
66
|
+
instructions_path = path / INSTRUCTIONS_FILE
|
|
67
|
+
|
|
68
|
+
if config_path.exists() and config_path.is_file():
|
|
69
|
+
conf = loads(config_path.read_text())
|
|
70
|
+
assert isinstance(conf, MutableMapping), f"Invalid content of {INSTRUCTIONS_CONF_FILE}"
|
|
71
|
+
ref_path = conf.get("ref")
|
|
72
|
+
if ref_path:
|
|
73
|
+
instructions_path = path / ref_path
|
|
74
|
+
|
|
75
|
+
if instructions_path.exists() and instructions_path.is_file():
|
|
76
|
+
content = instructions_path.read_text()
|
|
77
|
+
|
|
78
|
+
instruction_obj = self.__instructions.get(instructions_path.resolve())
|
|
79
|
+
if instruction_obj is None:
|
|
80
|
+
instruction_obj = Instruction({updated_file}, content)
|
|
81
|
+
self.__instructions[instructions_path] = instruction_obj
|
|
82
|
+
else:
|
|
83
|
+
instruction_obj.paths.add(updated_file)
|
|
84
|
+
|
|
85
|
+
def add_path(self, path: Path) -> None:
|
|
86
|
+
path = path.resolve()
|
|
87
|
+
if self.__root not in path.parents:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
updated_file = path
|
|
91
|
+
if not path.is_dir():
|
|
92
|
+
path = path.parent
|
|
93
|
+
|
|
94
|
+
while path != self.__root:
|
|
95
|
+
self.__fetch_instruction(path, updated_file)
|
|
96
|
+
path = path.parent
|
|
97
|
+
|
|
98
|
+
def produce_message(self) -> str | None:
|
|
99
|
+
if not self.__instructions:
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
instructions_content = ""
|
|
103
|
+
for instruction in self.__instructions.values():
|
|
104
|
+
files = "".join(sorted(str(p) for p in instruction.paths))
|
|
105
|
+
instructions_content += INSTRUCTION_TEMPLATE.format(
|
|
106
|
+
**{"files": files, "content": instruction.text}
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return COMMENT_TEMPLATE.format(**{"content": instructions_content})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
113
|
+
parser = argparse.ArgumentParser(
|
|
114
|
+
description="""
|
|
115
|
+
Collects all the relevant documentation to deploy the changes
|
|
116
|
+
in a pr given the list of involved files. It, then, produces a single
|
|
117
|
+
message to attach to the PR itself.
|
|
118
|
+
"""
|
|
119
|
+
)
|
|
120
|
+
parser.add_argument(
|
|
121
|
+
"-r",
|
|
122
|
+
"--root",
|
|
123
|
+
action="store",
|
|
124
|
+
help="""
|
|
125
|
+
The root directory of all changes
|
|
126
|
+
""",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
args = parser.parse_args()
|
|
130
|
+
|
|
131
|
+
root = args.root
|
|
132
|
+
assert (
|
|
133
|
+
root is not None and Path(root).exists() and Path(root).is_dir()
|
|
134
|
+
), "The root path does not exists or is not a directory."
|
|
135
|
+
message = InstructionsMessage(Path(root))
|
|
136
|
+
|
|
137
|
+
for path in sys.stdin:
|
|
138
|
+
print(f"Processing {path}", file=sys.stderr)
|
|
139
|
+
message.add_path(Path(path))
|
|
140
|
+
|
|
141
|
+
response = message.produce_message()
|
|
142
|
+
if response is not None:
|
|
143
|
+
print(response)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
main()
|
assistant/test_prdocs.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
from json import dumps
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Generator
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from assistant.prdocs import InstructionsMessage
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def valid_structure() -> Generator[str, None, None]:
|
|
14
|
+
with tempfile.TemporaryDirectory() as temp_dir:
|
|
15
|
+
full_path1 = Path(temp_dir) / "shared_config/kafka/generated_files"
|
|
16
|
+
full_path2 = Path(temp_dir) / "shared_config/kafka/other_files"
|
|
17
|
+
full_path3 = Path(temp_dir) / "shared_config/kafka/more_files"
|
|
18
|
+
os.makedirs(full_path1)
|
|
19
|
+
os.makedirs(full_path2)
|
|
20
|
+
os.makedirs(full_path3)
|
|
21
|
+
|
|
22
|
+
with open(full_path1 / "topic1.yaml", "w") as file:
|
|
23
|
+
file.write("something")
|
|
24
|
+
|
|
25
|
+
with open(full_path1 / "topic2.yaml", "w") as file:
|
|
26
|
+
file.write("something")
|
|
27
|
+
|
|
28
|
+
with open(full_path1 / "deploy_instructions.md", "w") as file:
|
|
29
|
+
file.write("Kafka content")
|
|
30
|
+
|
|
31
|
+
with open(full_path2 / "topic3.yaml", "w") as file:
|
|
32
|
+
file.write("something")
|
|
33
|
+
|
|
34
|
+
with open(full_path2 / "deploy_instructions.json", "w") as file:
|
|
35
|
+
file.write(dumps({"ref": str(full_path1 / "deploy_instructions.md")}))
|
|
36
|
+
|
|
37
|
+
yield temp_dir
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_empty(valid_structure) -> None:
|
|
41
|
+
message = InstructionsMessage(Path(valid_structure))
|
|
42
|
+
message.add_path(Path(valid_structure) / "shared_config/kafka/more_files/something.yaml")
|
|
43
|
+
|
|
44
|
+
assert message.produce_message() is None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_full_message(valid_structure) -> None:
|
|
48
|
+
message = InstructionsMessage(Path(valid_structure))
|
|
49
|
+
message.add_path(Path(valid_structure) / "shared_config/kafka/generated_files/topic1.yaml")
|
|
50
|
+
message.add_path(Path(valid_structure) / "shared_config/kafka/generated_files/topic2.yaml")
|
|
51
|
+
|
|
52
|
+
two_files_comment = f"""# Ops Assistant
|
|
53
|
+
|
|
54
|
+
The ops repo is a large monorepo and there are multiple processes
|
|
55
|
+
to deploy its content depending on what is changed.
|
|
56
|
+
This message is here to provide context.
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
Kafka content
|
|
60
|
+
|
|
61
|
+
Files changed:
|
|
62
|
+
```
|
|
63
|
+
{Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic1.yaml"}{Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic2.yaml"}
|
|
64
|
+
```
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
assert message.produce_message() == two_files_comment
|
|
69
|
+
|
|
70
|
+
message.add_path(Path(valid_structure) / "shared_config/kafka/other_files/topic3.yaml")
|
|
71
|
+
|
|
72
|
+
three_files_comment = f"""# Ops Assistant
|
|
73
|
+
|
|
74
|
+
The ops repo is a large monorepo and there are multiple processes
|
|
75
|
+
to deploy its content depending on what is changed.
|
|
76
|
+
This message is here to provide context.
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
Kafka content
|
|
80
|
+
|
|
81
|
+
Files changed:
|
|
82
|
+
```
|
|
83
|
+
{Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic1.yaml"}{Path(valid_structure).resolve() / "shared_config/kafka/generated_files/topic2.yaml"}{Path(valid_structure).resolve() / "shared_config/kafka/other_files/topic3.yaml"}
|
|
84
|
+
```
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
assert message.produce_message() == three_files_comment
|
|
File without changes
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
from config_builder.combined_generator import clean_all as clean_generated
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: Sequence[str] | None = None) -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
description="""
|
|
13
|
+
Clean up all generated and materialized files.
|
|
14
|
+
"""
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"root_dir",
|
|
18
|
+
type=str,
|
|
19
|
+
action="store",
|
|
20
|
+
help="""
|
|
21
|
+
The root directory of the shared configs.
|
|
22
|
+
""",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
args = parser.parse_args(argv)
|
|
26
|
+
materialized_dir = Path(args.root_dir)
|
|
27
|
+
clean_generated(materialized_dir)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
if __name__ == "__main__":
|
|
31
|
+
main()
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from shutil import rmtree
|
|
5
|
+
from typing import Generator, Sequence, Tuple
|
|
6
|
+
|
|
7
|
+
from config_builder.loaders import YamlFileLoader
|
|
8
|
+
from config_builder.merger import FileMerger
|
|
9
|
+
from config_builder.merger.libsonnet import LibsonnetMerger
|
|
10
|
+
from config_builder.merger.yamljson import YamlMerger
|
|
11
|
+
|
|
12
|
+
from config_builder.json_schema_validator import JsonSchemaValidator
|
|
13
|
+
|
|
14
|
+
DEFAULT_LIBSONNET_OUTPUT_FILE_NAME = "_generated.libsonnet"
|
|
15
|
+
DEFAULT_JSON_OUTPUT_FILE_NAME = "_generated.json"
|
|
16
|
+
CONFIG_GENERATOR_SETTINGS = "_config_generator.json"
|
|
17
|
+
GENERATED_DIR = "generated"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Outcome(Enum):
|
|
21
|
+
NEW = "new"
|
|
22
|
+
UNCHANGED = "unchanged"
|
|
23
|
+
UPDATED = "updated"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def combined_file_name(root_directory: Path, output_file_name: str) -> Path:
|
|
27
|
+
return root_directory / GENERATED_DIR / output_file_name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def combine_files(
|
|
31
|
+
merger: FileMerger,
|
|
32
|
+
root_directory: Path,
|
|
33
|
+
) -> str:
|
|
34
|
+
"""
|
|
35
|
+
Combine all the files in `root_directory` using the FileMerger
|
|
36
|
+
provided.
|
|
37
|
+
"""
|
|
38
|
+
assert root_directory.is_dir(), "Root directory must be a directory"
|
|
39
|
+
|
|
40
|
+
input_files = [x for x in Path(root_directory).iterdir() if x.is_file()]
|
|
41
|
+
for f in input_files:
|
|
42
|
+
merger.add_file(f)
|
|
43
|
+
|
|
44
|
+
return merger.serialize_content()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_schema(validator: JsonSchemaValidator, root_directory: Path) -> None:
|
|
48
|
+
input_files = [x for x in Path(root_directory).iterdir() if x.is_file()]
|
|
49
|
+
for f in input_files:
|
|
50
|
+
if str(f).endswith(("yaml", "yml")):
|
|
51
|
+
validator.validate_yaml(f)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def combine_and_write(
|
|
55
|
+
merger: FileMerger,
|
|
56
|
+
root_directory: Path,
|
|
57
|
+
output_file_name: str,
|
|
58
|
+
) -> Tuple[Outcome, Path]:
|
|
59
|
+
"""
|
|
60
|
+
Generates a unified file from the all the yaml/json/libsonnet files
|
|
61
|
+
contained in a directory.
|
|
62
|
+
|
|
63
|
+
A FileMerger is provided that is able to merge files of different types.
|
|
64
|
+
There are two implementations: one for yaml/json and the other for
|
|
65
|
+
libsonnet files.
|
|
66
|
+
|
|
67
|
+
The output is written in a predefined location `root_dir/generated` if
|
|
68
|
+
the generated file is in a ny way different from the pre-existing
|
|
69
|
+
combined import file in such directory.
|
|
70
|
+
|
|
71
|
+
It return whether the file was unchanged, new or updated.
|
|
72
|
+
"""
|
|
73
|
+
generated_file = combine_files(merger, root_directory)
|
|
74
|
+
if not (root_directory / GENERATED_DIR).exists():
|
|
75
|
+
os.makedirs(root_directory / GENERATED_DIR)
|
|
76
|
+
|
|
77
|
+
generated_file_path = combined_file_name(root_directory, output_file_name)
|
|
78
|
+
if not generated_file_path.exists():
|
|
79
|
+
outcome = Outcome.NEW
|
|
80
|
+
else:
|
|
81
|
+
with open(generated_file_path) as f:
|
|
82
|
+
existing_content = f.read()
|
|
83
|
+
outcome = Outcome.UNCHANGED if existing_content == generated_file else Outcome.UPDATED
|
|
84
|
+
|
|
85
|
+
if outcome != Outcome.UNCHANGED:
|
|
86
|
+
with open(generated_file_path, "w") as f:
|
|
87
|
+
f.write(generated_file)
|
|
88
|
+
|
|
89
|
+
return (outcome, generated_file_path)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def iterate_config_directories(root_dir: Path) -> Generator[Path, None, None]:
|
|
93
|
+
"""
|
|
94
|
+
Finds all the subdirectories of `root_dir` that are suitable for jsonnet
|
|
95
|
+
import file generation.
|
|
96
|
+
|
|
97
|
+
Such directories contain:
|
|
98
|
+
- a `_config_generator.json` file to mark it
|
|
99
|
+
- a `generated` subdirectory where the generated file will be (if it doesn't exsit, it'll get created in the upcoming combine_and_write step)
|
|
100
|
+
"""
|
|
101
|
+
for item in root_dir.rglob("*"):
|
|
102
|
+
if item.is_dir() and (item / CONFIG_GENERATOR_SETTINGS).exists():
|
|
103
|
+
yield item
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def iterate_roots_and_regions(
|
|
107
|
+
root_dir: Path,
|
|
108
|
+
) -> Generator[Tuple[Path, bool | None], None, None]:
|
|
109
|
+
"""
|
|
110
|
+
Extends `iterate_config_directories` by managing properly region
|
|
111
|
+
subdirectories.
|
|
112
|
+
|
|
113
|
+
If a root_directory (a directory with containing a CONFIG_GENERATOR_SETTINGS
|
|
114
|
+
file) contains a `regional_overrides` subdirectory, the root_directory
|
|
115
|
+
is also returned, as well as each dir inside 'regional_overrides'.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
for config_root in iterate_config_directories(root_dir):
|
|
119
|
+
yield (config_root, False)
|
|
120
|
+
regions_path = Path(config_root / "regional_overrides")
|
|
121
|
+
if regions_path.exists() and regions_path.is_dir():
|
|
122
|
+
for region_root in regions_path.iterdir():
|
|
123
|
+
if region_root.is_dir():
|
|
124
|
+
yield (region_root, True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def validate_all_files(root_directory: Path) -> None:
|
|
128
|
+
validator = JsonSchemaValidator()
|
|
129
|
+
for config_root, _ in iterate_roots_and_regions(root_directory):
|
|
130
|
+
input_files = [
|
|
131
|
+
x
|
|
132
|
+
for x in Path(config_root).iterdir()
|
|
133
|
+
if x.is_file() and str(x).endswith(("yaml", "yml"))
|
|
134
|
+
]
|
|
135
|
+
for f in input_files:
|
|
136
|
+
validator.validate_yaml(f)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def generate_all_files(root_directory: Path) -> Sequence[Tuple[Outcome, Path]]:
|
|
140
|
+
"""
|
|
141
|
+
Scan all the directories under `root_directory` to find directories
|
|
142
|
+
that has to be treated as root configs, thus requiring us to combine
|
|
143
|
+
multiple files into one to be imported by a jsonnet script.
|
|
144
|
+
"""
|
|
145
|
+
ret = []
|
|
146
|
+
|
|
147
|
+
for config_root, _ in iterate_roots_and_regions(root_directory):
|
|
148
|
+
outcome, output_file_name = combine_and_write(
|
|
149
|
+
LibsonnetMerger(), config_root, DEFAULT_LIBSONNET_OUTPUT_FILE_NAME
|
|
150
|
+
)
|
|
151
|
+
ret.append((outcome, output_file_name))
|
|
152
|
+
|
|
153
|
+
content_loader = YamlFileLoader(config_root)
|
|
154
|
+
outcome, output_file_name = combine_and_write(
|
|
155
|
+
YamlMerger(CONFIG_GENERATOR_SETTINGS, content_loader),
|
|
156
|
+
config_root,
|
|
157
|
+
DEFAULT_JSON_OUTPUT_FILE_NAME,
|
|
158
|
+
)
|
|
159
|
+
ret.append((outcome, output_file_name))
|
|
160
|
+
return ret
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def clean_all(root_directory: Path) -> None:
|
|
164
|
+
"""
|
|
165
|
+
Deletes all the generated files.
|
|
166
|
+
"""
|
|
167
|
+
for config_root, _ in iterate_roots_and_regions(root_directory):
|
|
168
|
+
if (config_root / GENERATED_DIR).exists():
|
|
169
|
+
assert (config_root / GENERATED_DIR).is_dir()
|
|
170
|
+
rmtree(config_root / GENERATED_DIR)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from sys import stderr
|
|
4
|
+
from sentry_kafka_schemas import get_topic, list_topics
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main() -> None:
|
|
9
|
+
root = Path(os.path.abspath(__file__)).parent.parent.parent.parent
|
|
10
|
+
output = f"{root}/shared_config/kafka/topics/generated/_generated_raw_topic_data.json"
|
|
11
|
+
|
|
12
|
+
print("Generating raw topic data", file=stderr)
|
|
13
|
+
file_path = Path(output)
|
|
14
|
+
|
|
15
|
+
topic_data = {
|
|
16
|
+
name: {"topic_creation_config": get_topic(name)["topic_creation_config"]}
|
|
17
|
+
for name in list_topics()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
os.makedirs(file_path.parent, exist_ok=True)
|
|
21
|
+
with open(file_path, "w") as f:
|
|
22
|
+
f.write(json.dumps(topic_data, indent=2) + "\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
import jsonschema
|
|
6
|
+
import yaml
|
|
7
|
+
from jsonschema.exceptions import ValidationError
|
|
8
|
+
from referencing import Registry, Resource
|
|
9
|
+
|
|
10
|
+
# TODO: this should not be hardcoded. Find a dynamic way to specify schema files, maybe in _config_generator.json?
|
|
11
|
+
SCHEMAS = {
|
|
12
|
+
"kafka/topics/regional_overrides/*/*.yaml": "kafka_topic_overrides.schema.json",
|
|
13
|
+
"kafka/consumer_groups/regional_overrides/*/*.yaml": "consumer_groups/regional_override_consumer_group.schema.json",
|
|
14
|
+
"kafka/consumer_groups/*.yaml": "consumer_groups/default_consumer_group.schema.json",
|
|
15
|
+
"kafka/clusters/*.yaml": "kafka_clusters.schema.json",
|
|
16
|
+
"kafka/consumer_types/*.yaml": "consumer_groups/consumer_type.schema.json",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
ROOT = Path("shared_config")
|
|
20
|
+
SCHEMAS_DIR = "schemas"
|
|
21
|
+
REGIONAL_OVERRIDE_DIR = "regional_overrides"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ValidationException(Exception):
|
|
25
|
+
def __init__(self, file: str, schema: str):
|
|
26
|
+
self.file = file
|
|
27
|
+
self.schema = schema
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class JsonSchemaValidator:
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
root: Optional[Path] = None,
|
|
34
|
+
schemas: Optional[Mapping[str, str]] = None,
|
|
35
|
+
schemas_dir: Optional[str] = None,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.root = ROOT if root is None else root
|
|
38
|
+
self.schema_root = (
|
|
39
|
+
(self.root / SCHEMAS_DIR) if schemas_dir is None else (self.root / schemas_dir)
|
|
40
|
+
)
|
|
41
|
+
self.schemas = SCHEMAS if schemas is None else schemas
|
|
42
|
+
self.registry = self.__build_registry()
|
|
43
|
+
|
|
44
|
+
def __build_registry(self) -> Registry[Mapping[str, Resource[Mapping[str, Any]]]]:
|
|
45
|
+
"""
|
|
46
|
+
The jsonschema's concept of registry is for $ref keyword to
|
|
47
|
+
resolve file paths and find the referenced object
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def retrieve_ref(ref: str) -> Resource[Mapping[str, Any]]:
|
|
51
|
+
ref_file = self.schema_root / Path(ref)
|
|
52
|
+
contents = json.loads(ref_file.read_text())
|
|
53
|
+
return Resource.from_contents(contents)
|
|
54
|
+
|
|
55
|
+
# library bug, typing of this argument is wrong
|
|
56
|
+
registry: Registry = Registry(retrieve=retrieve_ref) # type: ignore
|
|
57
|
+
return registry
|
|
58
|
+
|
|
59
|
+
def __get_schema(self, file: Path) -> Path | None:
|
|
60
|
+
"""
|
|
61
|
+
Finds which schema to use for a yaml file
|
|
62
|
+
"""
|
|
63
|
+
for glob, schema in self.schemas.items():
|
|
64
|
+
if file.match(str(self.root / glob)):
|
|
65
|
+
return self.schema_root / schema
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
def validate_yaml(self, file: Path) -> int | None:
|
|
69
|
+
schema = self.__get_schema(file)
|
|
70
|
+
if schema:
|
|
71
|
+
file_content = yaml.safe_load(file.read_text())
|
|
72
|
+
try:
|
|
73
|
+
jsonschema.validate(
|
|
74
|
+
schema=json.load(open(schema)),
|
|
75
|
+
instance=file_content,
|
|
76
|
+
registry=self.registry,
|
|
77
|
+
)
|
|
78
|
+
return 0
|
|
79
|
+
except ValidationError as e:
|
|
80
|
+
raise ValidationException(str(file), str(schema)) from e
|
|
81
|
+
return None
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Mapping, cast
|
|
6
|
+
|
|
7
|
+
from yaml import safe_load
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ContentLoader(ABC):
|
|
11
|
+
"""
|
|
12
|
+
Abstractions that allow the config materializers to load content
|
|
13
|
+
from different sources, being them directories or external
|
|
14
|
+
packages.
|
|
15
|
+
|
|
16
|
+
Instances of this class can be reused.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@abstractmethod
|
|
20
|
+
def load_dict(self, file_name: str) -> Mapping[str, Any] | None:
|
|
21
|
+
"""
|
|
22
|
+
Loads a dictionary from a a source. The file name can be an actual
|
|
23
|
+
file on disk or something logical representing a file.
|
|
24
|
+
`file_name` should contain the extension.
|
|
25
|
+
|
|
26
|
+
If a non existing file is asked for, this returns None. It does not
|
|
27
|
+
fail.
|
|
28
|
+
"""
|
|
29
|
+
raise NotImplementedError
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class YamlFileLoader(ContentLoader):
|
|
33
|
+
"""
|
|
34
|
+
Loader that loads simple yaml file from a directory
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, directory: Path) -> None:
|
|
38
|
+
assert (
|
|
39
|
+
directory.exists() and directory.is_dir()
|
|
40
|
+
), f"The provided path {directory} does not exists or it is not a directory"
|
|
41
|
+
self.__directory = directory
|
|
42
|
+
|
|
43
|
+
def load_dict(self, file_name: str) -> Mapping[str, Any] | None:
|
|
44
|
+
if (self.__directory / file_name).exists():
|
|
45
|
+
with open(self.__directory / file_name) as content:
|
|
46
|
+
return cast(Mapping[str, Any], safe_load(content))
|
|
47
|
+
|
|
48
|
+
return None
|