atlas-init 0.8.1__py3-none-any.whl → 0.10.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_root/aws_clean.py +108 -0
- atlas_init/cli_root/trigger.py +1 -1
- atlas_init/cli_tf/hcl/modifier2.py +51 -1
- atlas_init/settings/rich_utils.py +22 -0
- atlas_init/tf_ext/gen_examples.py +4 -2
- atlas_init/tf_ext/{gen_module_readme.py → gen_readme.py} +46 -26
- atlas_init/tf_ext/models.py +27 -1
- atlas_init/tf_ext/models_module.py +7 -3
- atlas_init/tf_ext/paths.py +18 -1
- atlas_init/tf_ext/run_tf.py +20 -0
- atlas_init/tf_ext/settings.py +8 -0
- atlas_init/tf_ext/tf_dep.py +69 -22
- atlas_init/tf_ext/tf_example_readme.py +392 -0
- atlas_init/tf_ext/tf_mod_gen.py +8 -25
- atlas_init/tf_ext/tf_modules.py +3 -1
- atlas_init/tf_ext/tf_ws.py +269 -0
- atlas_init/tf_ext/typer_app.py +3 -1
- atlas_init/typer_app.py +2 -1
- {atlas_init-0.8.1.dist-info → atlas_init-0.10.0.dist-info}/METADATA +2 -1
- {atlas_init-0.8.1.dist-info → atlas_init-0.10.0.dist-info}/RECORD +24 -20
- {atlas_init-0.8.1.dist-info → atlas_init-0.10.0.dist-info}/WHEEL +0 -0
- {atlas_init-0.8.1.dist-info → atlas_init-0.10.0.dist-info}/entry_points.txt +0 -0
- {atlas_init-0.8.1.dist-info → atlas_init-0.10.0.dist-info}/licenses/LICENSE +0 -0
atlas_init/tf_ext/tf_dep.py
CHANGED
@@ -1,16 +1,18 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
|
+
from functools import total_ordering
|
3
4
|
import logging
|
4
5
|
from collections import defaultdict
|
5
6
|
from pathlib import Path
|
6
|
-
from
|
7
|
+
from threading import RLock
|
8
|
+
from typing import Callable, Iterable, NamedTuple
|
7
9
|
|
8
10
|
import pydot
|
9
11
|
from ask_shell import ShellError, new_task, run_and_wait
|
10
12
|
from ask_shell._run import stop_runs_and_pool
|
11
13
|
from ask_shell.run_pool import run_pool
|
12
14
|
from model_lib import Entity, dump
|
13
|
-
from pydantic import BaseModel, Field
|
15
|
+
from pydantic import BaseModel, Field, model_validator
|
14
16
|
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
15
17
|
from typer import Typer
|
16
18
|
from zero_3rdparty.file_utils import ensure_parents_write_text
|
@@ -57,13 +59,25 @@ def tf_dep_graph(
|
|
57
59
|
example_dirs = get_example_directories(repo_path, skip_names)
|
58
60
|
logger.info(f"example_dirs: \n{'\n'.join(str(d) for d in sorted(example_dirs))}")
|
59
61
|
with new_task("Find terraform graphs", total=len(example_dirs)) as task:
|
60
|
-
atlas_graph =
|
62
|
+
atlas_graph = create_atlas_graph(example_dirs, task)
|
61
63
|
with new_task("Dump graph"):
|
62
64
|
graph_yaml = atlas_graph.dump_yaml()
|
63
65
|
ensure_parents_write_text(settings.atlas_graph_path, graph_yaml)
|
64
66
|
logger.info(f"Atlas graph dumped to {settings.atlas_graph_path}")
|
65
67
|
|
66
68
|
|
69
|
+
def create_atlas_graph(example_dirs: list[Path], task: new_task) -> AtlasGraph:
|
70
|
+
atlas_graph = AtlasGraph()
|
71
|
+
|
72
|
+
def on_graph(example_dir: Path, graph: pydot.Dot):
|
73
|
+
atlas_graph.add_edges(graph.get_edges())
|
74
|
+
atlas_graph.add_variable_edges(example_dir)
|
75
|
+
|
76
|
+
parse_graphs(on_graph, example_dirs, task)
|
77
|
+
|
78
|
+
return atlas_graph
|
79
|
+
|
80
|
+
|
67
81
|
def print_edges(graph: pydot.Dot):
|
68
82
|
edges = graph.get_edges()
|
69
83
|
for edge in edges:
|
@@ -79,9 +93,15 @@ class ResourceParts(NamedTuple):
|
|
79
93
|
return self.resource_type.split("_")[0]
|
80
94
|
|
81
95
|
|
96
|
+
@total_ordering
|
82
97
|
class ResourceRef(BaseModel):
|
83
98
|
full_ref: str
|
84
99
|
|
100
|
+
@model_validator(mode="after")
|
101
|
+
def ensure_plain(self):
|
102
|
+
self.full_ref = plain_name(self.full_ref)
|
103
|
+
return self
|
104
|
+
|
85
105
|
def _resource_parts(self) -> ResourceParts:
|
86
106
|
match self.full_ref.split("."):
|
87
107
|
case [resource_type, resource_name] if "_" in resource_type:
|
@@ -106,6 +126,11 @@ class ResourceRef(BaseModel):
|
|
106
126
|
def is_module(self) -> bool:
|
107
127
|
return self.full_ref.startswith(MODULE_PREFIX)
|
108
128
|
|
129
|
+
@property
|
130
|
+
def module_name(self) -> str:
|
131
|
+
assert self.is_module, f"ResourceRef {self.full_ref} is not a module"
|
132
|
+
return self.full_ref.removeprefix(MODULE_PREFIX).split(".")[0]
|
133
|
+
|
109
134
|
@property
|
110
135
|
def is_data(self) -> bool:
|
111
136
|
return self.full_ref.startswith(DATA_PREFIX)
|
@@ -114,6 +139,22 @@ class ResourceRef(BaseModel):
|
|
114
139
|
def resource_type(self) -> str:
|
115
140
|
return self._resource_parts().resource_type
|
116
141
|
|
142
|
+
def __lt__(self, other: object) -> bool:
|
143
|
+
if not isinstance(other, ResourceRef):
|
144
|
+
raise TypeError(f"cannot compare {type(self)} with {type(other)}")
|
145
|
+
return self.full_ref < other.full_ref
|
146
|
+
|
147
|
+
def __hash__(self) -> int:
|
148
|
+
return hash(self.full_ref)
|
149
|
+
|
150
|
+
def __eq__(self, other: object) -> bool:
|
151
|
+
if not isinstance(other, ResourceRef):
|
152
|
+
return NotImplemented
|
153
|
+
return self.full_ref == other.full_ref
|
154
|
+
|
155
|
+
def __str__(self) -> str:
|
156
|
+
return self.full_ref
|
157
|
+
|
117
158
|
|
118
159
|
class EdgeParsed(BaseModel):
|
119
160
|
parent: ResourceRef
|
@@ -149,7 +190,15 @@ class EdgeParsed(BaseModel):
|
|
149
190
|
|
150
191
|
|
151
192
|
def edge_plain(edge_endpoint: pydot.EdgeEndpoint) -> str:
|
152
|
-
return str(edge_endpoint)
|
193
|
+
return plain_name(str(edge_endpoint))
|
194
|
+
|
195
|
+
|
196
|
+
def node_plain(node: pydot.Node) -> str:
|
197
|
+
return plain_name(node.get_name())
|
198
|
+
|
199
|
+
|
200
|
+
def plain_name(name: str) -> str:
|
201
|
+
return name.strip('"').strip()
|
153
202
|
|
154
203
|
|
155
204
|
def edge_src_dest(edge: pydot.Edge) -> tuple[str, str]:
|
@@ -235,34 +284,27 @@ class AtlasGraph(Entity):
|
|
235
284
|
self.parent_child_edges[parent_type].add(child_type)
|
236
285
|
|
237
286
|
|
238
|
-
def parse_graphs(
|
239
|
-
|
287
|
+
def parse_graphs(
|
288
|
+
on_graph: Callable[[Path, pydot.Dot], None], example_dirs: list[Path], task: new_task, max_dirs: int = 1_000
|
289
|
+
) -> None:
|
240
290
|
with run_pool("parse example graphs", total=len(example_dirs)) as executor:
|
241
291
|
futures = {
|
242
292
|
executor.submit(parse_graph, example_dir): example_dir
|
243
293
|
for i, example_dir in enumerate(example_dirs)
|
244
294
|
if i < max_dirs
|
245
295
|
}
|
246
|
-
|
247
|
-
for future in futures:
|
296
|
+
for future, example_dir in futures.items():
|
248
297
|
try:
|
249
|
-
|
298
|
+
_, graph = future.result()
|
250
299
|
except ShellError as e:
|
251
|
-
logger.error(f"Error parsing graph for {
|
300
|
+
logger.error(f"Error parsing graph for {example_dir}: {e}")
|
252
301
|
continue
|
253
302
|
except KeyboardInterrupt:
|
254
303
|
logger.error("KeyboardInterrupt received, stopping graph parsing.")
|
255
304
|
stop_runs_and_pool("KeyboardInterrupt", immediate=True)
|
256
305
|
break
|
257
|
-
|
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)
|
306
|
+
on_graph(example_dir, graph)
|
264
307
|
task.update(advance=1)
|
265
|
-
return atlas_graph
|
266
308
|
|
267
309
|
|
268
310
|
class GraphParseError(Exception):
|
@@ -271,9 +313,13 @@ class GraphParseError(Exception):
|
|
271
313
|
super().__init__(f"Failed to parse graph for {example_dir}: {message}")
|
272
314
|
|
273
315
|
|
316
|
+
_lock = RLock()
|
317
|
+
|
318
|
+
|
274
319
|
def parse_graph_output(example_dir: Path, graph_output: str, verbose: bool = False) -> pydot.Dot:
|
275
320
|
assert graph_output, f"Graph output is empty for {example_dir}"
|
276
|
-
|
321
|
+
with _lock:
|
322
|
+
dots = pydot.graph_from_dot_data(graph_output)
|
277
323
|
if not dots:
|
278
324
|
raise GraphParseError(example_dir, f"No graphs found in the output:\n{graph_output}")
|
279
325
|
assert len(dots) == 1, f"Expected one graph for {example_dir}, got {len(dots)}"
|
@@ -297,10 +343,10 @@ class EmptyGraphOutputError(Exception):
|
|
297
343
|
@retry(
|
298
344
|
stop=stop_after_attempt(3),
|
299
345
|
wait=wait_fixed(1),
|
300
|
-
retry=retry_if_exception_type(EmptyGraphOutputError),
|
346
|
+
retry=retry_if_exception_type((EmptyGraphOutputError, GraphParseError)),
|
301
347
|
reraise=True,
|
302
348
|
)
|
303
|
-
def parse_graph(example_dir: Path) -> tuple[Path,
|
349
|
+
def parse_graph(example_dir: Path) -> tuple[Path, pydot.Dot]:
|
304
350
|
env_vars = {
|
305
351
|
"MONGODB_ATLAS_PREVIEW_PROVIDER_V2_ADVANCED_CLUSTER": "true" if is_v2_example_dir(example_dir) else "false",
|
306
352
|
}
|
@@ -309,7 +355,8 @@ def parse_graph(example_dir: Path) -> tuple[Path, str]:
|
|
309
355
|
run_and_wait("terraform init", cwd=example_dir, env=env_vars)
|
310
356
|
run = run_and_wait("terraform graph", cwd=example_dir, env=env_vars)
|
311
357
|
if graph_output := run.stdout_one_line:
|
312
|
-
|
358
|
+
graph = parse_graph_output(example_dir, graph_output) # just to make sure we get no errors
|
359
|
+
return example_dir, graph
|
313
360
|
raise EmptyGraphOutputError(example_dir)
|
314
361
|
|
315
362
|
|
@@ -0,0 +1,392 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
from collections import defaultdict
|
3
|
+
from contextlib import suppress
|
4
|
+
from functools import total_ordering
|
5
|
+
import logging
|
6
|
+
from pathlib import Path
|
7
|
+
from typing import Callable, ClassVar, Iterable, Protocol, TypeAlias
|
8
|
+
from ask_shell import new_task
|
9
|
+
from ask_shell.rich_live import get_live_console
|
10
|
+
from model_lib import Entity, parse_dict
|
11
|
+
from pydantic import Field, model_validator
|
12
|
+
import pydot
|
13
|
+
from rich.tree import Tree
|
14
|
+
import typer
|
15
|
+
|
16
|
+
from atlas_init.settings.rich_utils import tree_text
|
17
|
+
from atlas_init.tf_ext.gen_readme import ReadmeMarkers, generate_and_write_readme
|
18
|
+
from atlas_init.tf_ext.models import EmojiCounter
|
19
|
+
from atlas_init.tf_ext.models_module import README_FILENAME
|
20
|
+
from atlas_init.tf_ext.tf_dep import EdgeParsed, ResourceRef, node_plain, parse_graph, parse_graphs
|
21
|
+
|
22
|
+
logger = logging.getLogger(__name__)
|
23
|
+
MODULES_JSON_RELATIVE_PATH = ".terraform/modules/modules.json"
|
24
|
+
|
25
|
+
|
26
|
+
def tf_example_readme(
|
27
|
+
example_path: Path = typer.Option(
|
28
|
+
..., "-e", "--example-path", help="Path to the example directory", default_factory=Path.cwd
|
29
|
+
),
|
30
|
+
skip_module_details: list[str] = typer.Option(
|
31
|
+
..., "-s", "--skip-module-details", help="List of module details to skip", default_factory=list
|
32
|
+
),
|
33
|
+
):
|
34
|
+
with new_task("parse example graph"):
|
35
|
+
_, example_graph_dot = parse_graph(example_path) # ensures init is called
|
36
|
+
example_graph = ResourceGraph.from_graph(example_graph_dot)
|
37
|
+
with new_task("parse module graphs") as task:
|
38
|
+
modules_config = parse_modules_json(example_path, skip_module_details)
|
39
|
+
module_paths = modules_config.module_paths
|
40
|
+
module_graphs: dict[Path, ResourceGraph] = {}
|
41
|
+
|
42
|
+
def on_graph(example_dir: Path, graph: pydot.Dot):
|
43
|
+
module_graphs[example_dir] = ResourceGraph.from_graph(graph)
|
44
|
+
|
45
|
+
parse_graphs(on_graph, module_paths, task)
|
46
|
+
with new_task("create example module graph"):
|
47
|
+
# a graph when all resources in a module are treated as a single node.
|
48
|
+
modules_graph, emoji_counter = create_module_graph(example_graph)
|
49
|
+
with new_task(f"update {README_FILENAME}"):
|
50
|
+
modules_section = []
|
51
|
+
modules_trees_texts = []
|
52
|
+
module_dirs_used: set[Path] = set()
|
53
|
+
|
54
|
+
def add_module_tree(module_dir: Path):
|
55
|
+
# trees are only once per module, not per module instance
|
56
|
+
if module_dir in module_dirs_used:
|
57
|
+
return
|
58
|
+
module_dirs_used.add(module_dir)
|
59
|
+
module_graph = module_graphs[module_dir]
|
60
|
+
module_config = modules_config.get_by_path(module_dir)
|
61
|
+
emojis = ", ".join(emoji_counter.get_emoji(key) for key in module_config.keys)
|
62
|
+
if modules_config.skip_details(module_config):
|
63
|
+
tree = Tree(f"{module_dir.name} ({emojis})")
|
64
|
+
tree.add("details skipped")
|
65
|
+
else:
|
66
|
+
tree = module_graph.to_tree(f"{module_dir.name} ({emojis})", include_orphans=True)
|
67
|
+
get_live_console().print(tree)
|
68
|
+
modules_trees_texts.append(tree_text(tree))
|
69
|
+
|
70
|
+
for _, module_key in emoji_counter.emoji_name():
|
71
|
+
module_config = modules_config.get_by_key(module_key)
|
72
|
+
module_dir = module_config.absolute_path(example_path)
|
73
|
+
add_module_tree(module_dir)
|
74
|
+
|
75
|
+
def add_module_src(node: Tree, name: str) -> None:
|
76
|
+
config = modules_config.get_by_key(name)
|
77
|
+
node.add(f"{config.source}")
|
78
|
+
|
79
|
+
module_index_tree = emoji_tree(emoji_counter, tree_processor=add_module_src, name="Module Instances")
|
80
|
+
modules_section.extend(
|
81
|
+
[
|
82
|
+
"## Modules",
|
83
|
+
"",
|
84
|
+
"### Modules Instances",
|
85
|
+
"```sh",
|
86
|
+
tree_text(module_index_tree),
|
87
|
+
"```",
|
88
|
+
"### Module Definitions",
|
89
|
+
"",
|
90
|
+
"```sh",
|
91
|
+
"\n".join(modules_trees_texts),
|
92
|
+
"```",
|
93
|
+
"",
|
94
|
+
"### Graph with Dependencies",
|
95
|
+
"Any resource without a number prefix is defined at the root level.",
|
96
|
+
"",
|
97
|
+
as_mermaid(modules_graph),
|
98
|
+
]
|
99
|
+
)
|
100
|
+
generators = ReadmeMarkers.readme_generators()
|
101
|
+
generators.insert(1, (ReadmeMarkers.MODULES, lambda _: "\n".join(modules_section)))
|
102
|
+
generate_and_write_readme(
|
103
|
+
example_path,
|
104
|
+
generators=generators,
|
105
|
+
)
|
106
|
+
|
107
|
+
|
108
|
+
def create_module_graph(example_graph: ResourceGraph) -> tuple[ResourceGraph, EmojiCounter]:
|
109
|
+
emoji_counter = EmojiCounter()
|
110
|
+
|
111
|
+
def as_module_edge(parent: ResourceRef, child: ResourceRef) -> bool | ParentChild:
|
112
|
+
if not child.is_module:
|
113
|
+
return False
|
114
|
+
new_parent = add_emoji_prefix(parent, emoji_counter)
|
115
|
+
new_child = add_emoji_prefix(child, emoji_counter)
|
116
|
+
return new_parent, new_child
|
117
|
+
|
118
|
+
return create_subgraph(example_graph, as_module_edge), emoji_counter
|
119
|
+
|
120
|
+
|
121
|
+
def emoji_tree(
|
122
|
+
counter: EmojiCounter, *, tree_processor: Callable[[Tree, str], None] | None = None, name: str = "Emoji Tree"
|
123
|
+
) -> Tree:
|
124
|
+
tree = Tree(name)
|
125
|
+
for emoji, name in counter.emoji_name():
|
126
|
+
child = tree.add(f"{emoji} {name}")
|
127
|
+
if tree_processor:
|
128
|
+
tree_processor(child, name)
|
129
|
+
return tree
|
130
|
+
|
131
|
+
|
132
|
+
def add_emoji_prefix(ref: ResourceRef, emoji_counter: EmojiCounter) -> ResourceRef:
|
133
|
+
if ref.is_module:
|
134
|
+
return ResourceRef(full_ref=f"{emoji_counter.get_emoji(ref.module_name)} {ref.module_name}")
|
135
|
+
return ref
|
136
|
+
|
137
|
+
|
138
|
+
def strip_emoji_prefix(ref: ResourceRef) -> ResourceRef:
|
139
|
+
old_ref = ref.full_ref
|
140
|
+
return ResourceRef(full_ref=old_ref.split(" ")[1]) if " " in old_ref else ref
|
141
|
+
|
142
|
+
|
143
|
+
def as_module_name(ref: ResourceRef) -> str:
|
144
|
+
if ref.is_module:
|
145
|
+
return ref.module_name
|
146
|
+
return ""
|
147
|
+
|
148
|
+
|
149
|
+
def as_module_ref(ref: ResourceRef) -> ResourceRef:
|
150
|
+
if name := as_module_name(ref):
|
151
|
+
return ResourceRef(full_ref=f"module.{name}")
|
152
|
+
return ref
|
153
|
+
|
154
|
+
|
155
|
+
class _RootModuleIgnored(Exception):
|
156
|
+
pass
|
157
|
+
|
158
|
+
|
159
|
+
ParentChild: TypeAlias = tuple[ResourceRef, ResourceRef]
|
160
|
+
|
161
|
+
|
162
|
+
class ResourceGraph(Entity):
|
163
|
+
IGNORED_ORPHANS: ClassVar[set[str]] = {"node"} # some extra output from `terraform graph` command
|
164
|
+
parent_children: dict[ResourceRef, set[ResourceRef]] = Field(default_factory=lambda: defaultdict(set))
|
165
|
+
children_parents: dict[ResourceRef, set[ResourceRef]] = Field(default_factory=lambda: defaultdict(set))
|
166
|
+
orphans: set[ResourceRef] = Field(default_factory=set)
|
167
|
+
|
168
|
+
@classmethod
|
169
|
+
def from_graph(cls, graph: pydot.Dot) -> "ResourceGraph":
|
170
|
+
resource_graph = cls()
|
171
|
+
resource_graph.add_edges(graph.get_edges())
|
172
|
+
for orphan in graph.get_node_list():
|
173
|
+
name = node_plain(orphan)
|
174
|
+
if name in cls.IGNORED_ORPHANS:
|
175
|
+
continue
|
176
|
+
ref = ResourceRef(full_ref=name)
|
177
|
+
resource_graph.add_orphan_if_not_found(ref)
|
178
|
+
return resource_graph
|
179
|
+
|
180
|
+
def add_orphan_if_not_found(self, orphan: ResourceRef):
|
181
|
+
if orphan not in self.parent_children and orphan not in self.children_parents:
|
182
|
+
self.orphans.add(orphan)
|
183
|
+
|
184
|
+
def add_edges(self, edges: list[pydot.Edge]):
|
185
|
+
for edge in edges:
|
186
|
+
parsed = EdgeParsed.from_edge(edge)
|
187
|
+
parent = parsed.parent
|
188
|
+
child = parsed.child
|
189
|
+
if str(parent) == ".this":
|
190
|
+
logger.info(f"parent: {parent} child: {child}")
|
191
|
+
self.add_edge(parent, child)
|
192
|
+
|
193
|
+
def add_edge(self, parent: ResourceRef, child: ResourceRef):
|
194
|
+
self.parent_children[parent].add(child)
|
195
|
+
self.children_parents[child].add(parent)
|
196
|
+
|
197
|
+
def all_edges(self) -> list[ParentChild]:
|
198
|
+
return [(parent, child) for parent in self.parent_children for child in self.parent_children[parent]]
|
199
|
+
|
200
|
+
@property
|
201
|
+
def all_parents(self) -> set[ResourceRef]:
|
202
|
+
return set(self.parent_children.keys())
|
203
|
+
|
204
|
+
def sorted_parents(self) -> Iterable[ResourceRef]:
|
205
|
+
used_parents = set()
|
206
|
+
remaining_parents = self.all_parents
|
207
|
+
|
208
|
+
def next_parent() -> ResourceRef | None:
|
209
|
+
candidates = [parent for parent in remaining_parents if not self.children_parents[parent] - used_parents]
|
210
|
+
return min(candidates) if candidates else None
|
211
|
+
|
212
|
+
while remaining_parents:
|
213
|
+
parent = next_parent()
|
214
|
+
if parent is None:
|
215
|
+
break
|
216
|
+
used_parents.add(parent)
|
217
|
+
yield parent
|
218
|
+
remaining_parents.remove(parent)
|
219
|
+
|
220
|
+
def to_tree(self, example_dir_name: str, include_orphans: bool = False) -> Tree:
|
221
|
+
root = Tree(example_dir_name)
|
222
|
+
trees: dict[ResourceRef, Tree] = {}
|
223
|
+
|
224
|
+
for parent in self.sorted_parents():
|
225
|
+
parent_tree = trees.setdefault(parent, Tree(parent.full_ref))
|
226
|
+
for child_ref in sorted(self.parent_children[parent]):
|
227
|
+
child_tree = trees.setdefault(child_ref, Tree(child_ref.full_ref))
|
228
|
+
parent_tree.add(child_tree)
|
229
|
+
if not self.children_parents[parent]:
|
230
|
+
root.add(parent_tree)
|
231
|
+
if include_orphans:
|
232
|
+
for orphan in sorted(self.orphans):
|
233
|
+
if orphan not in trees:
|
234
|
+
root.add(Tree(orphan.full_ref))
|
235
|
+
return root
|
236
|
+
|
237
|
+
|
238
|
+
def as_mermaid(graph: ResourceGraph) -> str:
|
239
|
+
nodes: dict[str, str] = {}
|
240
|
+
|
241
|
+
def mermaid_ref(ref: ResourceRef) -> str:
|
242
|
+
no_emoji = strip_emoji_prefix(ref)
|
243
|
+
mermaid = str(no_emoji)
|
244
|
+
assert '"' not in mermaid, f"mermaid ref should not contain quotes: {mermaid}"
|
245
|
+
nodes[mermaid] = str(ref) # want original ref in label
|
246
|
+
return mermaid
|
247
|
+
|
248
|
+
def add_mermaid_edge(parent: ResourceRef, child: ResourceRef) -> str:
|
249
|
+
parent_ref = mermaid_ref(parent)
|
250
|
+
child_ref = mermaid_ref(child)
|
251
|
+
return f"{parent_ref} --> {child_ref}"
|
252
|
+
|
253
|
+
edges = [add_mermaid_edge(parent, child) for parent, child in graph.all_edges()]
|
254
|
+
return "\n".join(
|
255
|
+
[
|
256
|
+
"```mermaid",
|
257
|
+
"graph TD",
|
258
|
+
" " + "\n ".join(f'{mermaid}["{label}"]' for mermaid, label in sorted(nodes.items())),
|
259
|
+
" " + "\n ".join(sorted(edges)),
|
260
|
+
"```",
|
261
|
+
]
|
262
|
+
)
|
263
|
+
|
264
|
+
|
265
|
+
class EdgeFilter(Protocol):
|
266
|
+
def __call__(self, parent: ResourceRef, child: ResourceRef) -> bool | ParentChild: ...
|
267
|
+
|
268
|
+
|
269
|
+
def create_subgraph(graph: ResourceGraph, edge_filter: EdgeFilter) -> ResourceGraph:
|
270
|
+
subgraph = ResourceGraph()
|
271
|
+
for parent in graph.sorted_parents():
|
272
|
+
for child in sorted(graph.parent_children[parent]):
|
273
|
+
filter_response = edge_filter(parent, child)
|
274
|
+
match filter_response:
|
275
|
+
case True:
|
276
|
+
subgraph.add_edge(parent, child)
|
277
|
+
case False:
|
278
|
+
continue
|
279
|
+
case (parent, child) if parent != child:
|
280
|
+
subgraph.add_edge(parent, child)
|
281
|
+
return subgraph
|
282
|
+
|
283
|
+
|
284
|
+
@total_ordering
|
285
|
+
class ModuleExampleConfig(Entity):
|
286
|
+
keys: list[str]
|
287
|
+
rel_path: str = Field(alias="Dir", description="Relative path to the module example")
|
288
|
+
source: str = Field(
|
289
|
+
alias="Source",
|
290
|
+
description="Source of the module, for example: registry.terraform.io/terraform-aws-modules/vpc/aws",
|
291
|
+
)
|
292
|
+
version: str = Field(
|
293
|
+
alias="Version", description="Version of the module example, unset for local modules", default=""
|
294
|
+
)
|
295
|
+
|
296
|
+
@model_validator(mode="before")
|
297
|
+
@classmethod
|
298
|
+
def move_key(cls, v: dict):
|
299
|
+
key = v.pop("Key", None)
|
300
|
+
if key:
|
301
|
+
v["keys"] = [key]
|
302
|
+
if v.get("Dir", "") == ".":
|
303
|
+
raise _RootModuleIgnored()
|
304
|
+
return v
|
305
|
+
|
306
|
+
@model_validator(mode="after")
|
307
|
+
def validate_keys(self):
|
308
|
+
if not self.keys:
|
309
|
+
raise ValueError("keys is required")
|
310
|
+
return self
|
311
|
+
|
312
|
+
@property
|
313
|
+
def key(self) -> str:
|
314
|
+
return ",".join(sorted(self.keys))
|
315
|
+
|
316
|
+
def absolute_path(self, example_path: Path) -> Path:
|
317
|
+
path = example_path / self.rel_path
|
318
|
+
if not path.exists():
|
319
|
+
raise ValueError(f"module path not found for {self.key}: {path}")
|
320
|
+
return path
|
321
|
+
|
322
|
+
def __lt__(self, other: object) -> bool:
|
323
|
+
if not isinstance(other, ModuleExampleConfig):
|
324
|
+
raise TypeError(f"cannot compare {type(self)} with {type(other)}")
|
325
|
+
return self.key < other.key
|
326
|
+
|
327
|
+
|
328
|
+
class ModuleExampleConfigs(Entity):
|
329
|
+
example_path: Path
|
330
|
+
modules: dict[str, ModuleExampleConfig] = Field(default_factory=dict)
|
331
|
+
skip_module_details: set[str] = Field(default_factory=set)
|
332
|
+
|
333
|
+
@model_validator(mode="after")
|
334
|
+
def ensure_paths_exists(self):
|
335
|
+
not_exists: dict[str, Path] = {}
|
336
|
+
for config in self.modules.values():
|
337
|
+
path = config.absolute_path(self.example_path)
|
338
|
+
if not path.exists():
|
339
|
+
not_exists[config.key] = path
|
340
|
+
if not_exists:
|
341
|
+
raise ValueError(f"module paths not found: {not_exists}")
|
342
|
+
return self
|
343
|
+
|
344
|
+
@property
|
345
|
+
def module_paths(self) -> list[Path]:
|
346
|
+
return [config.absolute_path(self.example_path) for config in self.modules.values()]
|
347
|
+
|
348
|
+
def skip_details(self, config: ModuleExampleConfig) -> bool:
|
349
|
+
return any(key in self.skip_module_details for key in config.keys)
|
350
|
+
|
351
|
+
def get_by_path(self, module_dir: Path) -> ModuleExampleConfig:
|
352
|
+
for config in self.modules.values():
|
353
|
+
if config.absolute_path(self.example_path) == module_dir:
|
354
|
+
return config
|
355
|
+
raise ValueError(f"module not found for {module_dir}")
|
356
|
+
|
357
|
+
def get_by_key_or_none(self, key: str) -> ModuleExampleConfig | None:
|
358
|
+
return self.modules.get(key)
|
359
|
+
|
360
|
+
def get_by_key(self, key: str) -> ModuleExampleConfig:
|
361
|
+
return self.modules[key]
|
362
|
+
|
363
|
+
def modules_included(self, *, skip_keys: list[str]) -> list[ModuleExampleConfig]:
|
364
|
+
return [config for config in self.modules.values() if all(key not in skip_keys for key in config.keys)]
|
365
|
+
|
366
|
+
def add_module(self, config: ModuleExampleConfig):
|
367
|
+
key = config.keys[0]
|
368
|
+
assert len(config.keys) == 1, "only one key can be added at a time"
|
369
|
+
source = config.source
|
370
|
+
existing_config = next(
|
371
|
+
(existing_config for existing_config in self.modules.values() if source == existing_config.source),
|
372
|
+
None,
|
373
|
+
)
|
374
|
+
if existing_config:
|
375
|
+
existing_config.keys.append(key)
|
376
|
+
existing_config.keys.sort()
|
377
|
+
self.modules[key] = existing_config
|
378
|
+
else:
|
379
|
+
self.modules[key] = config
|
380
|
+
|
381
|
+
|
382
|
+
def parse_modules_json(example_path: Path, skip_module_details: list[str] | None = None) -> ModuleExampleConfigs:
|
383
|
+
configs = ModuleExampleConfigs(example_path=example_path, skip_module_details=set(skip_module_details or []))
|
384
|
+
module_json_path = example_path / MODULES_JSON_RELATIVE_PATH
|
385
|
+
if not module_json_path.exists():
|
386
|
+
return configs
|
387
|
+
module_json = parse_dict(module_json_path)
|
388
|
+
for raw in module_json.get("Modules", []):
|
389
|
+
with suppress(_RootModuleIgnored):
|
390
|
+
config = ModuleExampleConfig(**raw)
|
391
|
+
configs.add_module(config)
|
392
|
+
return configs
|
atlas_init/tf_ext/tf_mod_gen.py
CHANGED
@@ -12,7 +12,7 @@ from zero_3rdparty.file_utils import clean_dir, copy, ensure_parents_write_text
|
|
12
12
|
from atlas_init.cli_tf.example_update import UpdateExamples, update_examples
|
13
13
|
from atlas_init.tf_ext.args import TF_CLI_CONFIG_FILE_ARG
|
14
14
|
from atlas_init.tf_ext.gen_examples import generate_module_examples, read_example_dirs
|
15
|
-
from atlas_init.tf_ext.
|
15
|
+
from atlas_init.tf_ext.gen_readme import generate_and_write_readme
|
16
16
|
from atlas_init.tf_ext.gen_resource_main import generate_resource_main
|
17
17
|
from atlas_init.tf_ext.gen_resource_output import generate_resource_output
|
18
18
|
from atlas_init.tf_ext.gen_resource_variables import generate_module_variables
|
@@ -32,6 +32,7 @@ from atlas_init.tf_ext.plan_diffs import (
|
|
32
32
|
read_variables_path,
|
33
33
|
)
|
34
34
|
from atlas_init.tf_ext.provider_schema import AtlasSchemaInfo, ResourceSchema, parse_atlas_schema
|
35
|
+
from atlas_init.tf_ext.run_tf import validate_tf_workspace
|
35
36
|
from atlas_init.tf_ext.schema_to_dataclass import convert_and_format
|
36
37
|
from atlas_init.tf_ext.settings import TfExtSettings
|
37
38
|
|
@@ -131,29 +132,13 @@ def generate_resource_module(config: ModuleGenConfig, resource_type: str, atlas_
|
|
131
132
|
def finalize_and_validate_module(config: ModuleGenConfig) -> Path:
|
132
133
|
dump_versions_tf(config.module_out_path, skip_python=config.skip_python)
|
133
134
|
logger.info(f"Module dumped to {config.module_out_path}, running checks")
|
134
|
-
|
135
|
+
validate_tf_workspace(config.module_out_path, tf_cli_config_file=config.settings.tf_cli_config_file)
|
135
136
|
return config.module_out_path
|
136
137
|
|
137
138
|
|
138
139
|
OUT_BINARY_PATH = "tfplan.binary"
|
139
140
|
|
140
141
|
|
141
|
-
def validate_module(tf_workdir: Path, *, tf_cli_config_file: Path | None = None):
|
142
|
-
terraform_commands = [
|
143
|
-
"terraform init",
|
144
|
-
"terraform fmt .",
|
145
|
-
"terraform validate .",
|
146
|
-
]
|
147
|
-
env_extra = {}
|
148
|
-
if tf_cli_config_file:
|
149
|
-
env_extra["TF_CLI_CONFIG_FILE"] = str(tf_cli_config_file)
|
150
|
-
with new_task("Terraform Module Validate Checks", total=len(terraform_commands)) as task:
|
151
|
-
for command in terraform_commands:
|
152
|
-
attempts = 3 if command == "terraform init" else 1 # terraform init can fail due to network issues
|
153
|
-
run_and_wait(command, cwd=tf_workdir, env=env_extra, attempts=attempts)
|
154
|
-
task.update(advance=1)
|
155
|
-
|
156
|
-
|
157
142
|
def module_examples_and_readme(config: ModuleGenConfig, *, example_var_file: Path | None = None) -> Path:
|
158
143
|
path = config.module_out_path
|
159
144
|
if (examples_test := config.examples_test_path) and examples_test.exists():
|
@@ -165,7 +150,7 @@ def module_examples_and_readme(config: ModuleGenConfig, *, example_var_file: Pat
|
|
165
150
|
if examples_generated:
|
166
151
|
with run_pool("Validating examples", total=len(examples_generated), exit_wait_timeout=60) as pool:
|
167
152
|
for example_path in examples_generated:
|
168
|
-
pool.submit(
|
153
|
+
pool.submit(validate_tf_workspace, example_path)
|
169
154
|
|
170
155
|
attribute_descriptions = parse_attribute_descriptions(config.settings)
|
171
156
|
settings = config.settings
|
@@ -192,12 +177,10 @@ def module_examples_and_readme(config: ModuleGenConfig, *, example_var_file: Pat
|
|
192
177
|
if out_event.changes:
|
193
178
|
logger.info(f"Updated attribute descriptions: {len(out_event.changes)}")
|
194
179
|
run_and_wait("terraform fmt -recursive .", cwd=path, ansi_content=False, allow_non_zero_exit=True)
|
195
|
-
|
196
|
-
|
197
|
-
readme_content = generate_readme(config)
|
198
|
-
ensure_parents_write_text(readme_path, readme_content)
|
180
|
+
with new_task("Generating README.md"):
|
181
|
+
generate_and_write_readme(config.module_out_path)
|
199
182
|
if example_var_file:
|
200
|
-
examples = read_example_dirs(config.
|
183
|
+
examples = read_example_dirs(config.examples_path)
|
201
184
|
if examples:
|
202
185
|
failed_examples: list[Path] = []
|
203
186
|
with run_pool("Running terraform plan on examples", total=len(examples), exit_wait_timeout=60) as pool:
|
@@ -227,7 +210,7 @@ def example_plan_checks(config: ModuleGenConfig, timeout_all_seconds: int = 60)
|
|
227
210
|
with TemporaryDirectory() as temp_dir:
|
228
211
|
stored_plan = Path(temp_dir) / "plan.json"
|
229
212
|
tf_dir = config.example_path(check.example_name)
|
230
|
-
|
213
|
+
validate_tf_workspace(tf_dir)
|
231
214
|
var_arg = f" -var-file={variables_path}" if variables_path else ""
|
232
215
|
run_and_wait(f"terraform plan -out={OUT_BINARY_PATH}{var_arg}", cwd=tf_dir)
|
233
216
|
run_and_wait(f"terraform show -json {OUT_BINARY_PATH} > {stored_plan}", cwd=tf_dir)
|
atlas_init/tf_ext/tf_modules.py
CHANGED
@@ -175,7 +175,9 @@ def tf_modules(
|
|
175
175
|
logger.info(f"Missing modules: \n{'\n'.join(missing_resources)}")
|
176
176
|
|
177
177
|
|
178
|
-
def generate_module_graphs(
|
178
|
+
def generate_module_graphs(
|
179
|
+
skipped_module_resource_types: Iterable[str], settings: TfExtSettings, atlas_graph: AtlasGraph
|
180
|
+
):
|
179
181
|
tree = Tree(
|
180
182
|
"Module graphs",
|
181
183
|
)
|