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.
- atlas_init/__init__.py +1 -1
- atlas_init/cli_args.py +19 -1
- atlas_init/cli_tf/ci_tests.py +116 -24
- atlas_init/cli_tf/go_test_run.py +14 -2
- atlas_init/cli_tf/go_test_summary.py +334 -82
- atlas_init/cli_tf/go_test_tf_error.py +20 -12
- atlas_init/cli_tf/hcl/modifier2.py +120 -0
- atlas_init/cli_tf/openapi.py +10 -6
- atlas_init/html_out/__init__.py +0 -0
- atlas_init/html_out/md_export.py +143 -0
- atlas_init/sdk_ext/__init__.py +0 -0
- atlas_init/sdk_ext/go.py +102 -0
- atlas_init/sdk_ext/typer_app.py +18 -0
- atlas_init/settings/env_vars.py +13 -1
- atlas_init/settings/env_vars_generated.py +2 -0
- atlas_init/tf/.terraform.lock.hcl +33 -33
- atlas_init/tf/modules/aws_s3/provider.tf +1 -1
- atlas_init/tf/modules/aws_vpc/provider.tf +1 -1
- atlas_init/tf/modules/cloud_provider/provider.tf +1 -1
- atlas_init/tf/modules/cluster/provider.tf +1 -1
- atlas_init/tf/modules/encryption_at_rest/provider.tf +1 -1
- atlas_init/tf/modules/federated_vars/federated_vars.tf +1 -2
- atlas_init/tf/modules/federated_vars/provider.tf +1 -1
- atlas_init/tf/modules/project_extra/provider.tf +1 -1
- atlas_init/tf/modules/stream_instance/provider.tf +1 -1
- atlas_init/tf/modules/vpc_peering/provider.tf +1 -1
- atlas_init/tf/modules/vpc_privatelink/versions.tf +1 -1
- atlas_init/tf/providers.tf +1 -1
- atlas_init/tf_ext/__init__.py +0 -0
- atlas_init/tf_ext/__main__.py +3 -0
- atlas_init/tf_ext/api_call.py +325 -0
- atlas_init/tf_ext/args.py +17 -0
- atlas_init/tf_ext/constants.py +3 -0
- atlas_init/tf_ext/models.py +106 -0
- atlas_init/tf_ext/paths.py +126 -0
- atlas_init/tf_ext/settings.py +39 -0
- atlas_init/tf_ext/tf_dep.py +324 -0
- atlas_init/tf_ext/tf_modules.py +394 -0
- atlas_init/tf_ext/tf_vars.py +173 -0
- atlas_init/tf_ext/typer_app.py +24 -0
- {atlas_init-0.6.0.dist-info → atlas_init-0.7.0.dist-info}/METADATA +3 -2
- {atlas_init-0.6.0.dist-info → atlas_init-0.7.0.dist-info}/RECORD +45 -28
- atlas_init-0.7.0.dist-info/entry_points.txt +5 -0
- atlas_init-0.6.0.dist-info/entry_points.txt +0 -2
- {atlas_init-0.6.0.dist-info → atlas_init-0.7.0.dist-info}/WHEEL +0 -0
- {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()
|