atlas-init 0.6.0__py3-none-any.whl → 0.8.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 (65) hide show
  1. atlas_init/__init__.py +1 -1
  2. atlas_init/atlas_init.yaml +1 -0
  3. atlas_init/cli_args.py +19 -1
  4. atlas_init/cli_tf/ci_tests.py +116 -24
  5. atlas_init/cli_tf/example_update.py +20 -8
  6. atlas_init/cli_tf/go_test_run.py +14 -2
  7. atlas_init/cli_tf/go_test_summary.py +334 -82
  8. atlas_init/cli_tf/go_test_tf_error.py +20 -12
  9. atlas_init/cli_tf/hcl/modifier.py +22 -8
  10. atlas_init/cli_tf/hcl/modifier2.py +120 -0
  11. atlas_init/cli_tf/openapi.py +10 -6
  12. atlas_init/html_out/__init__.py +0 -0
  13. atlas_init/html_out/md_export.py +143 -0
  14. atlas_init/sdk_ext/__init__.py +0 -0
  15. atlas_init/sdk_ext/go.py +102 -0
  16. atlas_init/sdk_ext/typer_app.py +18 -0
  17. atlas_init/settings/env_vars.py +25 -3
  18. atlas_init/settings/env_vars_generated.py +2 -0
  19. atlas_init/tf/.terraform.lock.hcl +33 -33
  20. atlas_init/tf/modules/aws_s3/provider.tf +1 -1
  21. atlas_init/tf/modules/aws_vpc/provider.tf +1 -1
  22. atlas_init/tf/modules/cloud_provider/provider.tf +1 -1
  23. atlas_init/tf/modules/cluster/provider.tf +1 -1
  24. atlas_init/tf/modules/encryption_at_rest/provider.tf +1 -1
  25. atlas_init/tf/modules/federated_vars/federated_vars.tf +1 -2
  26. atlas_init/tf/modules/federated_vars/provider.tf +1 -1
  27. atlas_init/tf/modules/project_extra/provider.tf +1 -1
  28. atlas_init/tf/modules/stream_instance/provider.tf +1 -1
  29. atlas_init/tf/modules/vpc_peering/provider.tf +1 -1
  30. atlas_init/tf/modules/vpc_privatelink/versions.tf +1 -1
  31. atlas_init/tf/providers.tf +1 -1
  32. atlas_init/tf_ext/__init__.py +0 -0
  33. atlas_init/tf_ext/__main__.py +3 -0
  34. atlas_init/tf_ext/api_call.py +325 -0
  35. atlas_init/tf_ext/args.py +32 -0
  36. atlas_init/tf_ext/constants.py +3 -0
  37. atlas_init/tf_ext/gen_examples.py +141 -0
  38. atlas_init/tf_ext/gen_module_readme.py +131 -0
  39. atlas_init/tf_ext/gen_resource_main.py +195 -0
  40. atlas_init/tf_ext/gen_resource_output.py +71 -0
  41. atlas_init/tf_ext/gen_resource_variables.py +159 -0
  42. atlas_init/tf_ext/gen_versions.py +10 -0
  43. atlas_init/tf_ext/models.py +106 -0
  44. atlas_init/tf_ext/models_module.py +454 -0
  45. atlas_init/tf_ext/newres.py +90 -0
  46. atlas_init/tf_ext/paths.py +126 -0
  47. atlas_init/tf_ext/plan_diffs.py +140 -0
  48. atlas_init/tf_ext/provider_schema.py +199 -0
  49. atlas_init/tf_ext/py_gen.py +294 -0
  50. atlas_init/tf_ext/schema_to_dataclass.py +522 -0
  51. atlas_init/tf_ext/settings.py +188 -0
  52. atlas_init/tf_ext/tf_dep.py +324 -0
  53. atlas_init/tf_ext/tf_desc_gen.py +53 -0
  54. atlas_init/tf_ext/tf_desc_update.py +0 -0
  55. atlas_init/tf_ext/tf_mod_gen.py +263 -0
  56. atlas_init/tf_ext/tf_mod_gen_provider.py +124 -0
  57. atlas_init/tf_ext/tf_modules.py +395 -0
  58. atlas_init/tf_ext/tf_vars.py +158 -0
  59. atlas_init/tf_ext/typer_app.py +28 -0
  60. {atlas_init-0.6.0.dist-info → atlas_init-0.8.0.dist-info}/METADATA +5 -3
  61. {atlas_init-0.6.0.dist-info → atlas_init-0.8.0.dist-info}/RECORD +64 -31
  62. atlas_init-0.8.0.dist-info/entry_points.txt +5 -0
  63. atlas_init-0.6.0.dist-info/entry_points.txt +0 -2
  64. {atlas_init-0.6.0.dist-info → atlas_init-0.8.0.dist-info}/WHEEL +0 -0
  65. {atlas_init-0.6.0.dist-info → atlas_init-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,188 @@
1
+ import logging
2
+ from pathlib import Path
3
+ import re
4
+ from typing import ClassVar, Self
5
+
6
+ from model_lib import Entity, StaticSettings
7
+ from pydantic import model_validator
8
+ from zero_3rdparty.file_utils import ensure_parents_write_text
9
+ from zero_3rdparty.str_utils import ensure_suffix
10
+
11
+ from atlas_init.tf_ext.args import ENV_NAME_REPO_PATH_ATLAS_PROVIDER, TF_CLI_CONFIG_FILE_ENV_NAME
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _atlas_provider_installation = re.compile(r'"mongodb/mongodbatlas"\s*=\s*"(?P<repo_path>[^"]+)/bin"')
16
+
17
+
18
+ def parse_atlas_path_from_tf_cli_config_file(tf_cli_config_file: Path | None) -> Path | None:
19
+ assert tf_cli_config_file, "tf_cli_config_file is required to parse atlas path"
20
+ text = tf_cli_config_file.read_text()
21
+ if match := _atlas_provider_installation.search(text):
22
+ return Path(match.group("repo_path"))
23
+ return None
24
+
25
+
26
+ _tfrc_template = """\
27
+ provider_installation {
28
+ dev_overrides {
29
+ "mongodb/mongodbatlas" = "REPO_PATH_TF_PROVIDER"
30
+ }
31
+ direct {}
32
+ }
33
+ """
34
+
35
+
36
+ def tf_cli_config_file_content(repo_path_atlas: Path | None) -> str:
37
+ assert repo_path_atlas, "repo_path_atlas is required to export tf_cli_config_file"
38
+ return _tfrc_template.replace("REPO_PATH_TF_PROVIDER", ensure_suffix(str(repo_path_atlas), "/bin"))
39
+
40
+
41
+ def resource_type_name_no_provider(provider_name: str, resource_type: str) -> str:
42
+ return resource_type.removeprefix(provider_name).strip("_")
43
+
44
+
45
+ class RepoOut(Entity):
46
+ base: Path
47
+
48
+ @property
49
+ def resource_modules(self) -> Path:
50
+ return self.base / "resource_modules"
51
+
52
+ def resource_modules_provider_path(self, provider_name: str) -> Path:
53
+ return self.resource_modules / provider_name
54
+
55
+ def resource_module_path(self, provider_name: str, resource_type: str) -> Path:
56
+ return self.resource_modules_provider_path(provider_name) / resource_type_name_no_provider(
57
+ provider_name, resource_type
58
+ )
59
+
60
+ def dataclass_path(self, provider_name: str, resource_type: str) -> Path:
61
+ return self.py_provider_module(provider_name) / f"{resource_type}.py"
62
+
63
+ @property
64
+ def py_modules(self) -> Path:
65
+ return self.base / "py_modules"
66
+
67
+ @property
68
+ def config_path(self) -> Path:
69
+ return self.base / "config"
70
+
71
+ def py_provider_module(self, provider: str) -> Path:
72
+ return self.py_modules / f"tf_{provider}"
73
+
74
+ def provider_settings_path(self, provider: str) -> Path:
75
+ return self.config_path / f"{provider}.yaml"
76
+
77
+
78
+ class TfExtSettings(StaticSettings):
79
+ ENV_NAME_REPO_PATH_ATLAS_PROVIDER: ClassVar[str] = ENV_NAME_REPO_PATH_ATLAS_PROVIDER
80
+ ENV_NAME_TF_CLI_CONFIG_FILE: ClassVar[str] = TF_CLI_CONFIG_FILE_ENV_NAME
81
+
82
+ repo_path_atlas_provider: Path | None = None
83
+ tf_cli_config_file: Path | None = None
84
+ repo_out_path: Path | None = None
85
+
86
+ @model_validator(mode="after")
87
+ def infer_repo_path_atlas(self) -> Self:
88
+ if self.repo_path_atlas_provider is None and self.tf_cli_config_file is None:
89
+ raise ValueError("repo_path_atlas or tf_cli_config_file must be set")
90
+ if self.repo_path_atlas_provider is None:
91
+ self.repo_path_atlas_provider = parse_atlas_path_from_tf_cli_config_file(self.tf_cli_config_file)
92
+ if self.tf_cli_config_file is None:
93
+ cli_config_file_content = tf_cli_config_file_content(self.repo_path_atlas_provider)
94
+ self.tf_cli_config_file = self.static_root / "dev.tfrc"
95
+ ensure_parents_write_text(self.tf_cli_config_file, cli_config_file_content)
96
+ if self.tf_cli_config_file:
97
+ tf_cli_repo_path = parse_atlas_path_from_tf_cli_config_file(self.tf_cli_config_file)
98
+ assert tf_cli_repo_path == self.repo_path_atlas_provider, (
99
+ f"tf_cli_config_file does not match repo_path_atlas_provider {tf_cli_repo_path} != {self.repo_path_atlas_provider}"
100
+ )
101
+ return self
102
+
103
+ @property
104
+ def repo_out(self) -> RepoOut:
105
+ assert self.repo_out_path, "repo_out_path is required"
106
+ return RepoOut(base=self.repo_out_path)
107
+
108
+ @property
109
+ def atlas_graph_path(self) -> Path:
110
+ return self.static_root / "atlas_graph.yaml"
111
+
112
+ @property
113
+ def vars_file_path(self) -> Path:
114
+ return self.static_root / "tf_vars.yaml"
115
+
116
+ @property
117
+ def vars_external_file_path(self) -> Path:
118
+ return self.static_root / "tf_vars_external.yaml"
119
+
120
+ @property
121
+ def resource_types_file_path(self) -> Path:
122
+ return self.static_root / "tf_resource_types.yaml"
123
+
124
+ @property
125
+ def resource_types_external_file_path(self) -> Path:
126
+ return self.static_root / "tf_resource_types_external.yaml"
127
+
128
+ @property
129
+ def schema_resource_types_path(self) -> Path:
130
+ return self.static_root / "tf_schema_resource_types.yaml"
131
+
132
+ @property
133
+ def schema_resource_types_deprecated_path(self) -> Path:
134
+ return self.static_root / "tf_schema_resource_types_deprecated.yaml"
135
+
136
+ @property
137
+ def api_calls_path(self) -> Path:
138
+ return self.static_root / "tf_api_calls.yaml"
139
+
140
+ def pagination_output_path(self, query_string: str) -> Path:
141
+ return self.static_root / "pagination_output" / f"query_is_{query_string or 'empty'}.md"
142
+
143
+ @property
144
+ def new_res_path(self) -> Path:
145
+ return self.static_root / "newres"
146
+
147
+ @property
148
+ def modules_out_path(self) -> Path:
149
+ return self.static_root / "modules"
150
+
151
+ @property
152
+ def attribute_description_file_path(self) -> Path:
153
+ return self.static_root / "attribute_description.yaml"
154
+
155
+ @property
156
+ def attribute_description_manual_file_path(self) -> Path:
157
+ return self.static_root / "attribute_description_manual.yaml"
158
+
159
+ @property
160
+ def attribute_resource_descriptions_file_path(self) -> Path:
161
+ return self.static_root / "attribute_resource_descriptions.yaml"
162
+
163
+ @property
164
+ def attribute_resource_descriptions_manual_file_path(self) -> Path:
165
+ return self.static_root / "attribute_resource_descriptions_manual.yaml"
166
+
167
+ @property
168
+ def output_plan_dumps(self) -> Path:
169
+ return self.static_root / "output_plan_dumps"
170
+
171
+ @property
172
+ def plan_diff_output_path(self) -> Path:
173
+ return self.static_root / "plan_diff_output"
174
+
175
+ def provider_cache_dir(self, provider_name: str) -> Path:
176
+ return self.cache_root / "provider_cache" / provider_name
177
+
178
+
179
+ def init_tf_ext_settings(*, allow_empty_out_path: bool = False) -> TfExtSettings:
180
+ settings = TfExtSettings.from_env()
181
+ assert settings
182
+ logger.info("env-vars ready: ✅")
183
+ logger.info(f"repo_path_atlas: {settings.repo_path_atlas_provider}")
184
+ logger.info(f"tf_cli_config_file: {settings.tf_cli_config_file}")
185
+ if not allow_empty_out_path:
186
+ assert settings.repo_out
187
+ logger.info(f"Repo out path is: {settings.repo_out_path}")
188
+ return settings
@@ -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 import stop_runs_and_pool
11
+ from ask_shell.run_pool import run_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_ATLAS_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 TfExtSettings
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_ATLAS_ARG,
52
+ skip_names: list[str] = SKIP_EXAMPLES_DIRS_OPTION,
53
+ ):
54
+ settings = TfExtSettings.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()
@@ -0,0 +1,53 @@
1
+ import logging
2
+ from collections import defaultdict
3
+ from atlas_init.tf_ext.args import TF_CLI_CONFIG_FILE_ARG
4
+ from atlas_init.tf_ext.settings import TfExtSettings
5
+ from atlas_init.tf_ext.provider_schema import ResourceSchema, parse_atlas_schema
6
+ from model_lib import dump, parse_model
7
+ from zero_3rdparty.file_utils import ensure_parents_write_text
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def tf_desc_gen(
13
+ tf_cli_config_file: str = TF_CLI_CONFIG_FILE_ARG,
14
+ ):
15
+ settings = TfExtSettings.from_env()
16
+ out_path = settings.attribute_description_file_path
17
+ resource_out_path = settings.attribute_resource_descriptions_file_path
18
+ assert tf_cli_config_file
19
+ schema = parse_atlas_schema()
20
+ descriptions = {}
21
+ descriptions_by_resource: dict[str, dict[str, str]] = defaultdict(dict)
22
+ attr_desc_resource = {}
23
+
24
+ def add_description(resource_type: str, attr_name: str, description: str | None) -> None:
25
+ if not description:
26
+ return
27
+ descriptions_by_resource[resource_type][attr_name] = description
28
+ if existing := descriptions.get(attr_name):
29
+ if existing != description:
30
+ old_resource_type = attr_desc_resource[attr_name]
31
+ logger.info(
32
+ f"Descriptions differs between '{old_resource_type}' and '{resource_type}' for attribute '{attr_name}':\n{existing}\n{description}"
33
+ )
34
+ if len(existing) > len(description):
35
+ return
36
+ descriptions[attr_name] = description
37
+ attr_desc_resource[attr_name] = resource_type
38
+
39
+ for resource_type, resource_schema in schema.raw_resource_schema.items():
40
+ parsed_schema = parse_model(resource_schema, t=ResourceSchema)
41
+ schema_block = parsed_schema.block
42
+ for name, attribute in (schema_block.attributes or {}).items():
43
+ add_description(resource_type, name, attribute.description)
44
+ for name, block_type in (schema_block.block_types or {}).items():
45
+ add_description(resource_type, name, block_type.description)
46
+ descriptions_yaml = dump(dict(sorted(descriptions.items())), format="yaml")
47
+ ensure_parents_write_text(out_path, descriptions_yaml)
48
+ logger.info(f"Generated attribute descriptions to {out_path}")
49
+ resource_descriptions_yaml = dump(
50
+ {k: dict(sorted(v.items())) for k, v in sorted(descriptions_by_resource.items())}, format="yaml"
51
+ )
52
+ ensure_parents_write_text(resource_out_path, resource_descriptions_yaml)
53
+ logger.info(f"Generated attribute resource descriptions to {resource_out_path}")
File without changes