atlas-init 0.6.0__py3-none-any.whl → 0.7.0__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.
Files changed (46) hide show
  1. atlas_init/__init__.py +1 -1
  2. atlas_init/cli_args.py +19 -1
  3. atlas_init/cli_tf/ci_tests.py +116 -24
  4. atlas_init/cli_tf/go_test_run.py +14 -2
  5. atlas_init/cli_tf/go_test_summary.py +334 -82
  6. atlas_init/cli_tf/go_test_tf_error.py +20 -12
  7. atlas_init/cli_tf/hcl/modifier2.py +120 -0
  8. atlas_init/cli_tf/openapi.py +10 -6
  9. atlas_init/html_out/__init__.py +0 -0
  10. atlas_init/html_out/md_export.py +143 -0
  11. atlas_init/sdk_ext/__init__.py +0 -0
  12. atlas_init/sdk_ext/go.py +102 -0
  13. atlas_init/sdk_ext/typer_app.py +18 -0
  14. atlas_init/settings/env_vars.py +13 -1
  15. atlas_init/settings/env_vars_generated.py +2 -0
  16. atlas_init/tf/.terraform.lock.hcl +33 -33
  17. atlas_init/tf/modules/aws_s3/provider.tf +1 -1
  18. atlas_init/tf/modules/aws_vpc/provider.tf +1 -1
  19. atlas_init/tf/modules/cloud_provider/provider.tf +1 -1
  20. atlas_init/tf/modules/cluster/provider.tf +1 -1
  21. atlas_init/tf/modules/encryption_at_rest/provider.tf +1 -1
  22. atlas_init/tf/modules/federated_vars/federated_vars.tf +1 -2
  23. atlas_init/tf/modules/federated_vars/provider.tf +1 -1
  24. atlas_init/tf/modules/project_extra/provider.tf +1 -1
  25. atlas_init/tf/modules/stream_instance/provider.tf +1 -1
  26. atlas_init/tf/modules/vpc_peering/provider.tf +1 -1
  27. atlas_init/tf/modules/vpc_privatelink/versions.tf +1 -1
  28. atlas_init/tf/providers.tf +1 -1
  29. atlas_init/tf_ext/__init__.py +0 -0
  30. atlas_init/tf_ext/__main__.py +3 -0
  31. atlas_init/tf_ext/api_call.py +325 -0
  32. atlas_init/tf_ext/args.py +17 -0
  33. atlas_init/tf_ext/constants.py +3 -0
  34. atlas_init/tf_ext/models.py +106 -0
  35. atlas_init/tf_ext/paths.py +126 -0
  36. atlas_init/tf_ext/settings.py +39 -0
  37. atlas_init/tf_ext/tf_dep.py +324 -0
  38. atlas_init/tf_ext/tf_modules.py +394 -0
  39. atlas_init/tf_ext/tf_vars.py +173 -0
  40. atlas_init/tf_ext/typer_app.py +24 -0
  41. {atlas_init-0.6.0.dist-info → atlas_init-0.7.0.dist-info}/METADATA +3 -2
  42. {atlas_init-0.6.0.dist-info → atlas_init-0.7.0.dist-info}/RECORD +45 -28
  43. atlas_init-0.7.0.dist-info/entry_points.txt +5 -0
  44. atlas_init-0.6.0.dist-info/entry_points.txt +0 -2
  45. {atlas_init-0.6.0.dist-info → atlas_init-0.7.0.dist-info}/WHEEL +0 -0
  46. {atlas_init-0.6.0.dist-info → atlas_init-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections import defaultdict
5
+ from pathlib import Path
6
+ from typing import Iterable, NamedTuple
7
+
8
+ import pydot
9
+ from ask_shell import ShellError, new_task, run_and_wait
10
+ from ask_shell.run_pool import run_pool
11
+ from ask_shell._run import stop_runs_and_pool
12
+ from model_lib import Entity, dump
13
+ from pydantic import BaseModel, Field
14
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
15
+ from typer import Typer
16
+ from zero_3rdparty.file_utils import ensure_parents_write_text
17
+ from zero_3rdparty.iter_utils import flat_map
18
+
19
+ from atlas_init.settings.rich_utils import configure_logging
20
+ from atlas_init.tf_ext.args import REPO_PATH_ARG, SKIP_EXAMPLES_DIRS_OPTION
21
+ from atlas_init.tf_ext.constants import ATLAS_PROVIDER_NAME
22
+ from atlas_init.tf_ext.paths import find_variable_resource_type_usages, find_variables, get_example_directories
23
+ from atlas_init.tf_ext.settings import TfDepSettings
24
+
25
+ logger = logging.getLogger(__name__)
26
+ v2_grand_parent_dirs = {
27
+ "module_maintainer",
28
+ "module_user",
29
+ "migrate_cluster_to_advanced_cluster",
30
+ "mongodbatlas_backup_compliance_policy",
31
+ }
32
+ v2_parent_dir = {"cluster_with_schedule"}
33
+ MODULE_PREFIX = "module."
34
+ DATA_PREFIX = "data."
35
+ VARIABLE_RESOURCE_MAPPING: dict[str, str] = {
36
+ "org_id": "mongodbatlas_organization",
37
+ "project_id": "mongodbatlas_project",
38
+ "cluster_name": "mongodbatlas_advanced_cluster",
39
+ }
40
+ SKIP_NODES: set[str] = {"mongodbatlas_cluster", "mongodbatlas_flex_cluster"}
41
+ FORCE_INTERNAL_NODES: set[str] = {"mongodbatlas_project_ip_access_list"}
42
+
43
+
44
+ def is_v2_example_dir(example_dir: Path) -> bool:
45
+ parent_dir = example_dir.parent.name
46
+ grand_parent_dir = example_dir.parent.parent.name
47
+ return parent_dir in v2_parent_dir or grand_parent_dir in v2_grand_parent_dirs
48
+
49
+
50
+ def tf_dep_graph(
51
+ repo_path: Path = REPO_PATH_ARG,
52
+ skip_names: list[str] = SKIP_EXAMPLES_DIRS_OPTION,
53
+ ):
54
+ settings = TfDepSettings.from_env()
55
+ output_dir = settings.static_root
56
+ logger.info(f"Using output directory: {output_dir}")
57
+ example_dirs = get_example_directories(repo_path, skip_names)
58
+ logger.info(f"example_dirs: \n{'\n'.join(str(d) for d in sorted(example_dirs))}")
59
+ with new_task("Find terraform graphs", total=len(example_dirs)) as task:
60
+ atlas_graph = parse_graphs(example_dirs, task)
61
+ with new_task("Dump graph"):
62
+ graph_yaml = atlas_graph.dump_yaml()
63
+ ensure_parents_write_text(settings.atlas_graph_path, graph_yaml)
64
+ logger.info(f"Atlas graph dumped to {settings.atlas_graph_path}")
65
+
66
+
67
+ def print_edges(graph: pydot.Dot):
68
+ edges = graph.get_edges()
69
+ for edge in edges:
70
+ logger.info(f"{edge.get_source()} -> {edge.get_destination()}")
71
+
72
+
73
+ class ResourceParts(NamedTuple):
74
+ resource_type: str
75
+ resource_name: str
76
+
77
+ @property
78
+ def provider_name(self) -> str:
79
+ return self.resource_type.split("_")[0]
80
+
81
+
82
+ class ResourceRef(BaseModel):
83
+ full_ref: str
84
+
85
+ def _resource_parts(self) -> ResourceParts:
86
+ match self.full_ref.split("."):
87
+ case [resource_type, resource_name] if "_" in resource_type:
88
+ return ResourceParts(resource_type, resource_name)
89
+ case [*_, resource_type, resource_name] if "_" in resource_type:
90
+ return ResourceParts(resource_type, resource_name)
91
+ raise ValueError(f"Invalid resource reference: {self.full_ref}")
92
+
93
+ @property
94
+ def provider_name(self) -> str:
95
+ return self._resource_parts().provider_name
96
+
97
+ @property
98
+ def is_external(self) -> bool:
99
+ return self.provider_name != ATLAS_PROVIDER_NAME
100
+
101
+ @property
102
+ def is_atlas_resource(self) -> bool:
103
+ return not self.is_module and not self.is_data and self.provider_name == ATLAS_PROVIDER_NAME
104
+
105
+ @property
106
+ def is_module(self) -> bool:
107
+ return self.full_ref.startswith(MODULE_PREFIX)
108
+
109
+ @property
110
+ def is_data(self) -> bool:
111
+ return self.full_ref.startswith(DATA_PREFIX)
112
+
113
+ @property
114
+ def resource_type(self) -> str:
115
+ return self._resource_parts().resource_type
116
+
117
+
118
+ class EdgeParsed(BaseModel):
119
+ parent: ResourceRef
120
+ child: ResourceRef
121
+
122
+ @classmethod
123
+ def from_edge(cls, edge: pydot.Edge) -> "EdgeParsed":
124
+ return cls(
125
+ # edges shows from child --> parent, so we reverse the order
126
+ parent=ResourceRef(full_ref=edge_plain(edge.get_destination())),
127
+ child=ResourceRef(full_ref=edge_plain(edge.get_source())),
128
+ )
129
+
130
+ @property
131
+ def has_module_edge(self) -> bool:
132
+ return self.parent.is_module or self.child.is_module
133
+
134
+ @property
135
+ def has_data_edge(self) -> bool:
136
+ return self.parent.is_data or self.child.is_data
137
+
138
+ @property
139
+ def is_resource_edge(self) -> bool:
140
+ return not self.has_module_edge and not self.has_data_edge
141
+
142
+ @property
143
+ def is_external_to_internal_edge(self) -> bool:
144
+ return self.parent.is_external and self.child.is_atlas_resource
145
+
146
+ @property
147
+ def is_internal_atlas_edge(self) -> bool:
148
+ return self.parent.is_atlas_resource and self.child.is_atlas_resource
149
+
150
+
151
+ def edge_plain(edge_endpoint: pydot.EdgeEndpoint) -> str:
152
+ return str(edge_endpoint).strip('"').strip()
153
+
154
+
155
+ def edge_src_dest(edge: pydot.Edge) -> tuple[str, str]:
156
+ """Get the source and destination of the edge as plain strings."""
157
+ return edge_plain(edge.get_source()), edge_plain(edge.get_destination())
158
+
159
+
160
+ def skip_variable_edge(src: str, dst: str) -> bool:
161
+ # sourcery skip: assign-if-exp, boolean-if-exp-identity, reintroduce-else, remove-unnecessary-cast
162
+ if src == dst:
163
+ return True
164
+ if src == "mongodbatlas_advanced_cluster" and "_cluster" in dst:
165
+ return True
166
+ return False
167
+
168
+
169
+ class AtlasGraph(Entity):
170
+ # atlas_resource_type -> set[atlas_resource_type]
171
+ parent_child_edges: dict[str, set[str]] = Field(default_factory=lambda: defaultdict(set))
172
+ # atlas_resource_type -> set[external_resource_type]
173
+ external_parents: dict[str, set[str]] = Field(default_factory=lambda: defaultdict(set))
174
+ deprecated_resource_types: set[str] = Field(default_factory=set)
175
+
176
+ def all_parents(self, child: str) -> Iterable[str]:
177
+ for parent, children in self.parent_child_edges.items():
178
+ if child in children:
179
+ yield parent
180
+
181
+ def dump_yaml(self) -> str:
182
+ parent_child_edges = {name: sorted(children) for name, children in sorted(self.parent_child_edges.items())}
183
+ external_parents = {name: sorted(parents) for name, parents in sorted(self.external_parents.items())}
184
+ return dump(
185
+ {
186
+ "parent_child_edges": parent_child_edges,
187
+ "external_parents": external_parents,
188
+ },
189
+ format="yaml",
190
+ )
191
+
192
+ @property
193
+ def all_internal_nodes(self) -> set[str]:
194
+ return set(flat_map([src] + list(dsts) for src, dsts in self.parent_child_edges.items()))
195
+
196
+ def iterate_internal_edges(self) -> Iterable[tuple[str, str]]:
197
+ for parent, children in self.parent_child_edges.items():
198
+ for child in children:
199
+ yield parent, child
200
+
201
+ @property
202
+ def all_external_nodes(self) -> set[str]:
203
+ return set(flat_map([src] + list(dsts) for src, dsts in self.external_parents.items()))
204
+
205
+ def iterate_external_edges(self) -> Iterable[tuple[str, str]]:
206
+ for child, parents in self.external_parents.items():
207
+ for parent in parents:
208
+ yield parent, child
209
+
210
+ def add_edges(self, edges: list[pydot.Edge]):
211
+ for edge in edges:
212
+ parsed = EdgeParsed.from_edge(edge)
213
+ parent = parsed.parent
214
+ child = parsed.child
215
+ if parsed.is_internal_atlas_edge:
216
+ self.parent_child_edges[parent.resource_type].add(child.resource_type)
217
+ # edges shows from child --> parent, so we reverse the order
218
+ elif parsed.is_external_to_internal_edge:
219
+ if parent.provider_name in {"random", "cedar"}:
220
+ continue # skip random provider edges
221
+ self.external_parents[child.resource_type].add(parent.resource_type)
222
+
223
+ def add_variable_edges(self, example_dir: Path) -> None:
224
+ """Use the variables to find the resource dependencies."""
225
+ if not (variables := find_variables(example_dir / "variables.tf")):
226
+ return
227
+ usages = find_variable_resource_type_usages(set(variables), example_dir)
228
+ for variable, resource_types in usages.items():
229
+ if parent_type := VARIABLE_RESOURCE_MAPPING.get(variable):
230
+ for child_type in resource_types:
231
+ if skip_variable_edge(parent_type, child_type):
232
+ continue
233
+ if child_type.startswith(ATLAS_PROVIDER_NAME):
234
+ logger.info(f"Adding variable edge: {parent_type} -> {child_type}")
235
+ self.parent_child_edges[parent_type].add(child_type)
236
+
237
+
238
+ def parse_graphs(example_dirs: list[Path], task: new_task, max_workers: int = 16, max_dirs: int = 9999) -> AtlasGraph:
239
+ atlas_graph = AtlasGraph()
240
+ with run_pool("parse example graphs", total=len(example_dirs)) as executor:
241
+ futures = {
242
+ executor.submit(parse_graph, example_dir): example_dir
243
+ for i, example_dir in enumerate(example_dirs)
244
+ if i < max_dirs
245
+ }
246
+ graphs = {}
247
+ for future in futures:
248
+ try:
249
+ example_dir, graph_output = future.result()
250
+ except ShellError as e:
251
+ logger.error(f"Error parsing graph for {futures[future]}: {e}")
252
+ continue
253
+ except KeyboardInterrupt:
254
+ logger.error("KeyboardInterrupt received, stopping graph parsing.")
255
+ stop_runs_and_pool("KeyboardInterrupt", immediate=True)
256
+ break
257
+ try:
258
+ graph = graphs[example_dir] = parse_graph_output(example_dir, graph_output)
259
+ except GraphParseError as e:
260
+ logger.error(e)
261
+ continue
262
+ atlas_graph.add_edges(graph.get_edges())
263
+ atlas_graph.add_variable_edges(example_dir)
264
+ task.update(advance=1)
265
+ return atlas_graph
266
+
267
+
268
+ class GraphParseError(Exception):
269
+ def __init__(self, example_dir: Path, message: str):
270
+ self.example_dir = example_dir
271
+ super().__init__(f"Failed to parse graph for {example_dir}: {message}")
272
+
273
+
274
+ def parse_graph_output(example_dir: Path, graph_output: str, verbose: bool = False) -> pydot.Dot:
275
+ assert graph_output, f"Graph output is empty for {example_dir}"
276
+ dots = pydot.graph_from_dot_data(graph_output) # not thread safe, so we use the main thread here instead
277
+ if not dots:
278
+ raise GraphParseError(example_dir, f"No graphs found in the output:\n{graph_output}")
279
+ assert len(dots) == 1, f"Expected one graph for {example_dir}, got {len(dots)}"
280
+ graph = dots[0]
281
+ edges = graph.get_edges()
282
+ if not edges:
283
+ logger.info(f"No edges found in graph for {example_dir}")
284
+ if verbose:
285
+ print_edges(graph)
286
+ return graph
287
+
288
+
289
+ class EmptyGraphOutputError(Exception):
290
+ """Raised when the graph output is empty."""
291
+
292
+ def __init__(self, example_dir: Path):
293
+ self.example_dir = example_dir
294
+ super().__init__(f"Graph output is empty for {example_dir}")
295
+
296
+
297
+ @retry(
298
+ stop=stop_after_attempt(3),
299
+ wait=wait_fixed(1),
300
+ retry=retry_if_exception_type(EmptyGraphOutputError),
301
+ reraise=True,
302
+ )
303
+ def parse_graph(example_dir: Path) -> tuple[Path, str]:
304
+ env_vars = {
305
+ "MONGODB_ATLAS_PREVIEW_PROVIDER_V2_ADVANCED_CLUSTER": "true" if is_v2_example_dir(example_dir) else "false",
306
+ }
307
+ lock_file = example_dir / ".terraform.lock.hcl"
308
+ if not lock_file.exists():
309
+ run_and_wait("terraform init", cwd=example_dir, env=env_vars)
310
+ run = run_and_wait("terraform graph", cwd=example_dir, env=env_vars)
311
+ if graph_output := run.stdout_one_line:
312
+ return example_dir, graph_output
313
+ raise EmptyGraphOutputError(example_dir)
314
+
315
+
316
+ def typer_main():
317
+ app = Typer()
318
+ app.command()(tf_dep_graph)
319
+ configure_logging(app)
320
+ app()
321
+
322
+
323
+ if __name__ == "__main__":
324
+ typer_main()