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.
- atlas_init/__init__.py +1 -1
- atlas_init/atlas_init.yaml +1 -0
- atlas_init/cli_args.py +19 -1
- atlas_init/cli_tf/ci_tests.py +116 -24
- atlas_init/cli_tf/example_update.py +20 -8
- 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/modifier.py +22 -8
- 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 +25 -3
- 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 +32 -0
- atlas_init/tf_ext/constants.py +3 -0
- atlas_init/tf_ext/gen_examples.py +141 -0
- atlas_init/tf_ext/gen_module_readme.py +131 -0
- atlas_init/tf_ext/gen_resource_main.py +195 -0
- atlas_init/tf_ext/gen_resource_output.py +71 -0
- atlas_init/tf_ext/gen_resource_variables.py +159 -0
- atlas_init/tf_ext/gen_versions.py +10 -0
- atlas_init/tf_ext/models.py +106 -0
- atlas_init/tf_ext/models_module.py +454 -0
- atlas_init/tf_ext/newres.py +90 -0
- atlas_init/tf_ext/paths.py +126 -0
- atlas_init/tf_ext/plan_diffs.py +140 -0
- atlas_init/tf_ext/provider_schema.py +199 -0
- atlas_init/tf_ext/py_gen.py +294 -0
- atlas_init/tf_ext/schema_to_dataclass.py +522 -0
- atlas_init/tf_ext/settings.py +188 -0
- atlas_init/tf_ext/tf_dep.py +324 -0
- atlas_init/tf_ext/tf_desc_gen.py +53 -0
- atlas_init/tf_ext/tf_desc_update.py +0 -0
- atlas_init/tf_ext/tf_mod_gen.py +263 -0
- atlas_init/tf_ext/tf_mod_gen_provider.py +124 -0
- atlas_init/tf_ext/tf_modules.py +395 -0
- atlas_init/tf_ext/tf_vars.py +158 -0
- atlas_init/tf_ext/typer_app.py +28 -0
- {atlas_init-0.6.0.dist-info → atlas_init-0.8.0.dist-info}/METADATA +5 -3
- {atlas_init-0.6.0.dist-info → atlas_init-0.8.0.dist-info}/RECORD +64 -31
- atlas_init-0.8.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.8.0.dist-info}/WHEEL +0 -0
- {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
|