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
@@ -1,8 +1,9 @@
1
+ from __future__ import annotations
1
2
  import logging
2
3
  from collections import defaultdict
3
4
  from copy import deepcopy
4
5
  from pathlib import Path
5
- from typing import Callable
6
+ from typing import Callable, Protocol
6
7
 
7
8
  import hcl2
8
9
  from lark import Token, Tree
@@ -40,14 +41,16 @@ def is_block_type(tree: Tree, block_type: str) -> bool:
40
41
  return False
41
42
 
42
43
 
43
- def update_description(tree: Tree, new_descriptions: dict[str, str], existing_names: dict[str, list[str]]) -> Tree:
44
+ def update_description(
45
+ path: Path, tree: Tree, get_new_description: NewDescription, existing_names: dict[str, list[str]]
46
+ ) -> Tree:
44
47
  new_children = tree.children.copy()
45
48
  variable_body = new_children[2]
46
49
  assert variable_body.data == "body"
47
50
  name = token_name(new_children[1])
48
51
  old_description = read_description_attribute(variable_body)
49
52
  existing_names[name].append(old_description)
50
- new_description = new_descriptions.get(name, "")
53
+ new_description = get_new_description(name, old_description, path)
51
54
  if not new_description:
52
55
  debug_log(f"no description found for variable {name}", 0)
53
56
  return tree
@@ -60,6 +63,8 @@ def token_name(token: Token | Tree) -> str:
60
63
  return token.value.strip('"')
61
64
  if isinstance(token, Tree) and token.data == "identifier":
62
65
  return token.children[0].value.strip('"') # type: ignore
66
+ if isinstance(token, Tree) and isinstance(token.data, Token) and token.data.value == "heredoc_template_trim":
67
+ return token.children[0].value.strip('"') # type: ignore
63
68
  err_msg = f"unexpected token type {type(token)} for token name"
64
69
  raise ValueError(err_msg)
65
70
 
@@ -103,10 +108,11 @@ def read_description_attribute(tree: Tree) -> str:
103
108
 
104
109
 
105
110
  def create_description_attribute(description_value: str) -> Tree:
111
+ token_value = f"<<-EOT\n{description_value}\nEOT\n" if "\n" in description_value else f'"{description_value}"'
106
112
  children = [
107
113
  Tree(Token("RULE", "identifier"), [Token("NAME", "description")]),
108
114
  Token("EQ", " ="),
109
- Tree(Token("RULE", "expr_term"), [Token("STRING_LIT", f'"{description_value}"')]),
115
+ Tree(Token("RULE", "expr_term"), [Token("STRING_LIT", token_value)]),
110
116
  ]
111
117
  return Tree(Token("RULE", "attribute"), children)
112
118
 
@@ -129,9 +135,14 @@ def process_generic(
129
135
  return Tree(node.data, new_children)
130
136
 
131
137
 
138
+ class NewDescription(Protocol):
139
+ def __call__(self, name: str, old_description: str, path: Path) -> str: ...
140
+
141
+
132
142
  def process_descriptions(
143
+ path: Path,
133
144
  node: Tree,
134
- name_updates: dict[str, str],
145
+ new_description: NewDescription,
135
146
  existing_names: dict[str, list[str]],
136
147
  depth=0,
137
148
  *,
@@ -141,7 +152,7 @@ def process_descriptions(
141
152
  return is_block_type(tree, block_type)
142
153
 
143
154
  def tree_call(tree: Tree) -> Tree:
144
- return update_description(tree, name_updates, existing_names)
155
+ return update_description(path, tree, new_description, existing_names)
145
156
 
146
157
  return process_generic(
147
158
  node,
@@ -151,14 +162,17 @@ def process_descriptions(
151
162
  )
152
163
 
153
164
 
154
- def update_descriptions(tf_path: Path, new_names: dict[str, str], block_type: str) -> tuple[str, dict[str, list[str]]]:
165
+ def update_descriptions(
166
+ tf_path: Path, new_description: NewDescription, block_type: str
167
+ ) -> tuple[str, dict[str, list[str]]]:
155
168
  tree = safe_parse(tf_path)
156
169
  if tree is None:
157
170
  return "", {}
158
171
  existing_descriptions = defaultdict(list)
159
172
  new_tree = process_descriptions(
173
+ tf_path,
160
174
  tree,
161
- new_names,
175
+ new_description,
162
176
  existing_descriptions,
163
177
  block_type=block_type,
164
178
  )
@@ -1,3 +1,4 @@
1
+ from collections import defaultdict
1
2
  import logging
2
3
  from contextlib import suppress
3
4
  from pathlib import Path
@@ -57,6 +58,110 @@ def attribute_transfomer(attr_name: str, obj_key: str, new_value: str) -> tuple[
57
58
  return AttributeTransformer(with_meta=True), changes
58
59
 
59
60
 
61
+ def variable_reader(tree: Tree) -> dict[str, str | None]:
62
+ """
63
+ Reads the variable names from a parsed HCL2 tree.
64
+ Returns a variable_name -> description, None if no description is found.
65
+ """
66
+ variables: dict[str, str | None] = {}
67
+
68
+ class DescriptionReader(DictTransformer):
69
+ def __init__(self, with_meta: bool = False, *, name: str):
70
+ super().__init__(with_meta)
71
+ self.name = name
72
+ self.description: str | None = None
73
+
74
+ def attribute(self, args: list) -> Attribute:
75
+ name = args[0]
76
+ if name == "description":
77
+ description = _parse_attribute_value(args)
78
+ self.description = description
79
+ return super().attribute(args)
80
+
81
+ class BlockReader(Transformer):
82
+ @v_args(tree=True)
83
+ def block(self, block_tree: Tree) -> Tree:
84
+ current_block_name = _identifier_name(block_tree)
85
+ if current_block_name == "variable":
86
+ variable_name = token_name(block_tree.children[1])
87
+ reader = DescriptionReader(name=variable_name)
88
+ reader.transform(block_tree)
89
+ variables[variable_name] = reader.description
90
+ return block_tree
91
+
92
+ BlockReader().transform(tree)
93
+ return variables
94
+
95
+
96
+ def _parse_attribute_value(args: list) -> str:
97
+ description = args[-1]
98
+ return token_name(description) if isinstance(description, Token) else description.strip('"')
99
+
100
+
101
+ def resource_types_vars_usage(tree: Tree) -> dict[str, dict[str, str]]:
102
+ """
103
+ Reads the resource types and their variable usages from a parsed HCL2 tree.
104
+ Returns a dictionary where keys are resource type names and values are dictionaries
105
+ of variable names and the attribute paths they are used in.
106
+ """
107
+ resource_types: dict[str, dict[str, str]] = defaultdict(dict)
108
+
109
+ class ResourceBlockAttributeReader(DictTransformer):
110
+ def __init__(self, with_meta: bool = False, resource_type: str = ""):
111
+ self.resource_type = resource_type
112
+ resource_types.setdefault(self.resource_type, {})
113
+ super().__init__(with_meta)
114
+
115
+ def attribute(self, args: list) -> Attribute:
116
+ try:
117
+ value = _parse_attribute_value(args)
118
+ except AttributeError:
119
+ return super().attribute(args)
120
+ if value.startswith("var."):
121
+ variable_name = value[4:]
122
+ resource_types[self.resource_type][variable_name] = args[0]
123
+ return super().attribute(args)
124
+
125
+ class BlockReader(Transformer):
126
+ @v_args(tree=True)
127
+ def block(self, block_tree: Tree) -> Tree:
128
+ block_resource_name = _block_resource_name(block_tree)
129
+ if block_resource_name is not None:
130
+ ResourceBlockAttributeReader(with_meta=True, resource_type=block_resource_name).transform(block_tree)
131
+ return block_tree
132
+
133
+ BlockReader().transform(tree)
134
+ return resource_types
135
+
136
+
137
+ def variable_usages(variable_names: set[str], tree: Tree) -> dict[str, set[str]]:
138
+ usages = defaultdict(set)
139
+ current_resource_type = None
140
+
141
+ class ResourceBlockAttributeReader(DictTransformer):
142
+ def attribute(self, args: list) -> Attribute:
143
+ attr_value = args[-1]
144
+ if isinstance(attr_value, str) and attr_value.startswith("var."):
145
+ variable_name = attr_value[4:]
146
+ if variable_name in variable_names:
147
+ assert current_resource_type is not None, "current_resource_type should not be None"
148
+ usages[variable_name].add(current_resource_type)
149
+ return super().attribute(args)
150
+
151
+ class BlockReader(Transformer):
152
+ @v_args(tree=True)
153
+ def block(self, block_tree: Tree) -> Tree:
154
+ block_resource_name = _block_resource_name(block_tree)
155
+ if block_resource_name is not None and block_resource_name.startswith("mongodbatlas_"):
156
+ nonlocal current_resource_type
157
+ current_resource_type = block_resource_name
158
+ ResourceBlockAttributeReader().transform(block_tree)
159
+ return block_tree
160
+
161
+ BlockReader().transform(tree)
162
+ return usages
163
+
164
+
60
165
  def _identifier_name(tree: Tree) -> str | None:
61
166
  with suppress(Exception):
62
167
  identifier_tree = tree.children[0]
@@ -67,6 +172,21 @@ def _identifier_name(tree: Tree) -> str | None:
67
172
  return name_token.value
68
173
 
69
174
 
175
+ def _block_resource_name(tree: Tree) -> str | None:
176
+ block_name = _identifier_name(tree)
177
+ if block_name != "resource":
178
+ return None
179
+ token = tree.children[1]
180
+ return token_name(token)
181
+
182
+
183
+ def token_name(token):
184
+ assert isinstance(token, Token)
185
+ token_value = token.value
186
+ assert isinstance(token_value, str)
187
+ return token_value.strip('"')
188
+
189
+
70
190
  def write_tree(tree: Tree) -> str:
71
191
  return writes(tree)
72
192
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import datetime
3
4
  import logging
4
5
  import re
5
6
  from collections.abc import Iterable
@@ -76,7 +77,7 @@ class OpenapiSchema(Entity):
76
77
  def create_method(self, path: str) -> dict | None:
77
78
  return self.paths.get(path, {}).get("post")
78
79
 
79
- def read_method(self, path: str) -> dict | None:
80
+ def get_method(self, path: str) -> dict | None:
80
81
  return self.paths.get(path, {}).get("get")
81
82
 
82
83
  def delete_method(self, path: str) -> dict | None:
@@ -88,10 +89,13 @@ class OpenapiSchema(Entity):
88
89
  def put_method(self, path: str) -> dict | None:
89
90
  return self.paths.get(path, {}).get("patch")
90
91
 
91
- def methods(self, path: str) -> Iterable[dict]:
92
+ def methods_with_name(self, path: str) -> Iterable[tuple[str, dict]]:
92
93
  for method_name in ["post", "get", "delete", "patch", "put"]:
93
94
  if method := self.paths.get(path, {}).get(method_name):
94
- yield method
95
+ yield method_name, method
96
+
97
+ def methods(self, path: str) -> Iterable[dict]:
98
+ yield from (method for _, method in self.methods_with_name(path))
95
99
 
96
100
  def method_refs(self, path: str) -> Iterable[str]:
97
101
  for method in self.methods(path):
@@ -145,7 +149,7 @@ class OpenapiSchema(Entity):
145
149
  if ref := value.get("schema", {}).get("$ref"):
146
150
  yield ref
147
151
 
148
- def _unpack_schema_versions(self, response: dict) -> list[str]:
152
+ def _unpack_schema_versions(self, response: dict) -> list[datetime.date]:
149
153
  content: dict[str, dict] = {**response.get("content", {})}
150
154
  versions = []
151
155
  while content:
@@ -159,7 +163,7 @@ class OpenapiSchema(Entity):
159
163
  versions.append(version)
160
164
  return versions
161
165
 
162
- def path_method_api_versions(self) -> Iterable[tuple[PathMethodCode, list[str]]]:
166
+ def path_method_api_versions(self) -> Iterable[tuple[PathMethodCode, list[datetime.date]]]:
163
167
  for path, methods in self.paths.items():
164
168
  for method_name, method_dict in methods.items():
165
169
  if not isinstance(method_dict, dict):
@@ -302,7 +306,7 @@ def add_api_spec_info(schema: SchemaV2, api_spec_path: Path, *, minimal_refs: bo
302
306
  for property_dict in api_spec.schema_properties(req_ref):
303
307
  parse_api_spec_param(api_spec, property_dict, resource)
304
308
  for path in resource.paths:
305
- read_method = api_spec.read_method(path)
309
+ read_method = api_spec.get_method(path)
306
310
  if not read_method:
307
311
  continue
308
312
  for param in read_method.get("parameters", []):
File without changes
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from concurrent.futures import Future
4
+ from contextlib import suppress
5
+ from datetime import datetime
6
+ from typing import ClassVar
7
+ from ask_shell import ShellRun, confirm, kill, run, run_and_wait
8
+ from ask_shell.models import ShellRunEventT, ShellRunStdOutput
9
+ from zero_3rdparty import str_utils
10
+ from zero_3rdparty.file_utils import copy, ensure_parents_write_text
11
+ from zero_3rdparty.future import chain_future
12
+ from atlas_init.settings.env_vars import AtlasInitSettings
13
+ from pathlib import Path
14
+ from model_lib import Event
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ class MonthlyReportPaths(Event):
20
+ summary_path: Path
21
+ error_only_path: Path
22
+ details_dir: Path
23
+ summary_name: str
24
+ daily_path: Path
25
+
26
+ ERROR_ONLY_SUFFIX: ClassVar[str] = "_error-only.md"
27
+ DAILY_SUFFIX: ClassVar[str] = "_daily.md"
28
+
29
+ def export_to_dir(self, out_dir: Path) -> None:
30
+ for path in [self.summary_path, self.error_only_path, self.daily_path]:
31
+ if path.exists():
32
+ ensure_parents_write_text(out_dir / path.name, path.read_text())
33
+ if self.details_dir.exists():
34
+ copy(self.details_dir, out_dir / self.details_dir.name, clean_dest=True)
35
+
36
+ @classmethod
37
+ def from_settings(cls, settings: AtlasInitSettings, summary_name: str) -> MonthlyReportPaths:
38
+ summary_path = settings.github_ci_summary_dir / str_utils.ensure_suffix(summary_name, ".md")
39
+ return cls(
40
+ summary_path=summary_path,
41
+ error_only_path=settings.github_ci_summary_dir
42
+ / str_utils.ensure_suffix(summary_name, MonthlyReportPaths.ERROR_ONLY_SUFFIX),
43
+ details_dir=settings.github_ci_summary_details_path(summary_name, "dummy").parent,
44
+ summary_name=summary_name,
45
+ daily_path=settings.github_ci_summary_dir / f"{summary_path.stem}{MonthlyReportPaths.DAILY_SUFFIX}",
46
+ )
47
+
48
+
49
+ CI_TESTS_DIR_NAME = "ci-tests"
50
+ MKDOCS_SERVE_TIMEOUT = 120
51
+ MKDOCS_SERVE_URL = "http://127.0.0.1:8000"
52
+
53
+
54
+ def export_ci_tests_markdown_to_html(settings: AtlasInitSettings, report_paths: MonthlyReportPaths) -> None:
55
+ html_out = settings.atlas_init_static_html_path
56
+ if not html_out or not html_out.exists():
57
+ return
58
+ ci_tests_dir = html_out / CI_TESTS_DIR_NAME
59
+ docs_out_dir = ci_tests_dir / "docs"
60
+ report_paths.export_to_dir(docs_out_dir)
61
+ index_md_content = create_index_md(docs_out_dir)
62
+ ensure_parents_write_text(docs_out_dir / "index.md", index_md_content)
63
+ server_url, run_event = start_mkdocs_serve(ci_tests_dir)
64
+ try:
65
+ if confirm(f"do you want to open the html docs? {server_url}", default=False):
66
+ run_and_wait(f'open -a "Google Chrome" {server_url}')
67
+ if confirm("Finished testing html docs?", default=False):
68
+ pass
69
+ except BaseException as e:
70
+ raise e
71
+ finally:
72
+ kill(run_event, reason="Done with html docs check")
73
+ if confirm("Are docs ok to build and push?", default=False):
74
+ build_and_push(ci_tests_dir, report_paths.summary_name)
75
+
76
+
77
+ def create_index_md(docs_out_dir: Path) -> str:
78
+ """
79
+ tree -L 1 docs
80
+ docs
81
+ ├── 2025-06-26_details
82
+ ├── 2025-06-26_.md
83
+ ├── 2025-06-26.md
84
+ ├── 2025-06-26_error-only.md
85
+ ├── index.md
86
+ ├── javascript
87
+ └── stylesheets
88
+ """
89
+ md_files = {f.name: f for f in docs_out_dir.glob("*.md") if f.name != "index.md"}
90
+ parsed_dates = []
91
+ for md_file in md_files.values():
92
+ with suppress(ValueError):
93
+ parsed_dates.append(datetime.strptime(md_file.stem, "%Y-%m-%d"))
94
+ parsed_dates.sort(reverse=True)
95
+
96
+ def date_row(date: datetime) -> str:
97
+ summary_filename = f"{date.strftime('%Y-%m-%d')}.md"
98
+ error_only_filename = f"{date.strftime('%Y-%m-%d')}{MonthlyReportPaths.ERROR_ONLY_SUFFIX}"
99
+ daily_filename = f"{date.strftime('%Y-%m-%d')}{MonthlyReportPaths.DAILY_SUFFIX}"
100
+ line_links = [f"[{date.strftime('%Y-%m-%d')}](./{summary_filename})"]
101
+ if error_only_filename in md_files:
102
+ line_links.append(f"[{date.strftime('%Y-%m-%d')} Error Only](./{error_only_filename})")
103
+ if daily_filename in md_files:
104
+ line_links.append(f"[{date.strftime('%Y-%m-%d')} Daily Errors](./{daily_filename})")
105
+ return f"- {', '.join(line_links)}"
106
+
107
+ md_content = [
108
+ "# Welcome to CI Tests",
109
+ "",
110
+ *[date_row(dt) for dt in parsed_dates],
111
+ "",
112
+ ]
113
+ return "\n".join(md_content)
114
+
115
+
116
+ def start_mkdocs_serve(ci_tests_dir: Path) -> tuple[str, ShellRun]:
117
+ future = Future()
118
+
119
+ def on_message(event: ShellRunEventT) -> bool:
120
+ match event:
121
+ case ShellRunStdOutput(_, content) if f"Serving on {MKDOCS_SERVE_URL}" in content:
122
+ logger.info(f"Docs server ready @ {MKDOCS_SERVE_URL}")
123
+ future.set_result(None)
124
+ return True
125
+ return False
126
+
127
+ run_event = run(
128
+ "uv run mkdocs serve", cwd=ci_tests_dir, message_callbacks=[on_message], print_prefix="mkdocs serve"
129
+ )
130
+ chain_future(run_event._complete_flag, future)
131
+ try:
132
+ future.result(timeout=MKDOCS_SERVE_TIMEOUT)
133
+ except BaseException as e:
134
+ kill(run_event, reason=f"Failed to start mkdocs serve, timeout after {MKDOCS_SERVE_TIMEOUT} seconds")
135
+ raise e
136
+ return MKDOCS_SERVE_URL, run_event
137
+
138
+
139
+ def build_and_push(ci_tests_dir: Path, summary_name: str) -> None:
140
+ run_and_wait("uv run mkdocs build", cwd=ci_tests_dir, print_prefix="build")
141
+ run_and_wait("git add .", cwd=ci_tests_dir, print_prefix="add")
142
+ run_and_wait(f"git commit -m 'update ci tests {summary_name}'", cwd=ci_tests_dir, print_prefix="commit")
143
+ run_and_wait("git push", cwd=ci_tests_dir, print_prefix="push")
File without changes
@@ -0,0 +1,102 @@
1
+ from contextlib import suppress
2
+ import logging
3
+ from pathlib import Path
4
+ from ask_shell import confirm, run_and_wait
5
+ from model_lib import dump, parse_model
6
+ import typer
7
+ from zero_3rdparty.file_utils import clean_dir, copy, ensure_parents_write_text
8
+ from atlas_init.cli_args import ParsedPaths, option_sdk_repo_path, option_mms_repo_path
9
+ from atlas_init.cli_tf.openapi import OpenapiSchema
10
+
11
+ _go_mod_line = "replace go.mongodb.org/atlas-sdk/v20250312005 v20250312005.0.0 => ../atlas-sdk-go"
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def go(
16
+ mms_path_str: str = option_mms_repo_path,
17
+ sdk_repo_path_str: str = option_sdk_repo_path,
18
+ mms_branch: str = typer.Option("master", "--mms-branch", help="Branch to use for mms"),
19
+ skip_mms_openapi: bool = typer.Option(
20
+ False, "-smms", "--skip-mms-openapi", help="Skip mms openapi generation, use existing file instead"
21
+ ),
22
+ ):
23
+ paths = ParsedPaths.from_strings(sdk_repo_path_str=sdk_repo_path_str, mms_path=mms_path_str)
24
+ mms_path = paths.mms_repo_path
25
+ assert mms_path, "mms_path is required"
26
+ sdk_path = paths.sdk_repo_path
27
+ assert sdk_path, "sdk_path is required"
28
+ openapi_path = safe_openapi_path(mms_path) if skip_mms_openapi else generate_openapi_spec(mms_path, mms_branch)
29
+ openapi_path = transform_openapi(openapi_path, sdk_path / "openapi/openapi-mms.yaml")
30
+ generate_go_sdk(sdk_path, openapi_path)
31
+ confirm(f"Have you remembered to add to your go.mod file: {_go_mod_line}")
32
+
33
+
34
+ def transform_openapi(old: Path, dest_path: Path) -> Path:
35
+ api_spec = parse_model(old, t=OpenapiSchema)
36
+ new_api_spec = api_spec.model_dump()
37
+ for path in api_spec.paths.keys():
38
+ for method_name, method in api_spec.methods_with_name(path):
39
+ responses = method.get("responses", {})
40
+ for code, multi_responses in responses.items():
41
+ with suppress(AlreadySingleVersion):
42
+ new_api_spec["paths"][path][method_name]["responses"][code]["content"] = use_a_single_version(
43
+ multi_responses, api_spec, path
44
+ )
45
+ if request_body := method.get("requestBody", {}):
46
+ with suppress(AlreadySingleVersion):
47
+ new_api_spec["paths"][path][method_name]["requestBody"]["content"] = use_a_single_version(
48
+ request_body, api_spec, path
49
+ )
50
+ dest_yaml = dump(new_api_spec, "yaml")
51
+ ensure_parents_write_text(dest_path, dest_yaml)
52
+ return dest_path
53
+
54
+
55
+ class AlreadySingleVersion(Exception):
56
+ pass
57
+
58
+
59
+ def use_a_single_version(multi_content: dict, api_spec: OpenapiSchema, path: str) -> dict[str, dict]:
60
+ if api_versions := api_spec._unpack_schema_versions(multi_content):
61
+ if len(api_versions) > 1:
62
+ latest_version = max(api_versions)
63
+ last_header = f"application/vnd.atlas.{latest_version}+json"
64
+ old_content = multi_content["content"]
65
+ assert last_header in old_content, f"failed to find {last_header} for {path} in {old_content.keys()}"
66
+ return {last_header: old_content[last_header]}
67
+ raise AlreadySingleVersion
68
+
69
+
70
+ def generate_openapi_spec(mms_path: Path, mms_branch: str) -> Path:
71
+ run_and_wait(f"git stash && git checkout {mms_branch}", cwd=mms_path)
72
+ bazelisk_bin_run = run_and_wait("mise which bazelisk", cwd=mms_path)
73
+ bazelisk_bin = bazelisk_bin_run.stdout_one_line
74
+ assert Path(bazelisk_bin).exists(), f"not found {bazelisk_bin}"
75
+ openapi_run = run_and_wait(f"{bazelisk_bin} run //server:mms-openapi", cwd=mms_path, print_prefix="mms-openapi")
76
+ assert openapi_run.clean_complete, f"failed to run {openapi_run}"
77
+ return safe_openapi_path(mms_path)
78
+
79
+
80
+ def safe_openapi_path(mms_path: Path) -> Path:
81
+ openapi_path = mms_path / "server/openapi/services/openapi-mms.json"
82
+ assert openapi_path.exists(), f"not found {openapi_path}"
83
+ return openapi_path
84
+
85
+
86
+ def generate_go_sdk(repo_path: Path, openapi_path: Path) -> None:
87
+ SDK_FOLDER = repo_path / "admin"
88
+ clean_dir(SDK_FOLDER, recreate=True)
89
+ generate_script = repo_path / "tools/scripts/generate.sh"
90
+ assert generate_script.exists(), f"not found {generate_script}"
91
+ openapi_folder = repo_path / "openapi"
92
+ openapi_dest_path = openapi_folder / openapi_path.name
93
+ if openapi_path != openapi_dest_path:
94
+ copy(openapi_path, openapi_dest_path)
95
+ generate_env = {
96
+ "OPENAPI_FOLDER": str(openapi_folder),
97
+ "OPENAPI_FILE_NAME": openapi_path.name,
98
+ "SDK_FOLDER": str(SDK_FOLDER),
99
+ }
100
+ run_and_wait(f"{generate_script}", cwd=repo_path / "tools", env=generate_env, print_prefix="go sdk create")
101
+ mockery_script = repo_path / "tools/scripts/generate_mocks.sh"
102
+ run_and_wait(f"{mockery_script}", cwd=repo_path / "tools", print_prefix="go sdk mockery")
@@ -0,0 +1,18 @@
1
+ from ask_shell import configure_logging
2
+ from typer import Typer
3
+
4
+
5
+ def typer_main():
6
+ from atlas_init.sdk_ext import go
7
+
8
+ app = Typer(
9
+ name="sdk-ext",
10
+ help="SDK extension commands for Atlas Init",
11
+ )
12
+ app.command(name="go")(go.go)
13
+ configure_logging(app)
14
+ app()
15
+
16
+
17
+ if __name__ == "__main__":
18
+ typer_main()
@@ -9,7 +9,7 @@ from typing import Any, NamedTuple, TypeVar
9
9
 
10
10
  from model_lib import StaticSettings, parse_payload
11
11
  from pydantic import BaseModel, ValidationError, field_validator
12
- from zero_3rdparty import iter_utils
12
+ from zero_3rdparty import iter_utils, str_utils
13
13
 
14
14
  from atlas_init.settings.config import (
15
15
  AtlasInitConfig,
@@ -54,6 +54,7 @@ class AtlasInitSettings(StaticSettings):
54
54
  atlas_init_tf_src_path: Path = DEFAULT_TF_SRC_PATH # /tf directory of repo
55
55
  atlas_init_tf_schema_config_path: Path = DEFAULT_ATLAS_INIT_SCHEMA_CONFIG_PATH # /terraform.yaml
56
56
  atlas_init_schema_out_path: Path | None = None # override this for the generated schema
57
+ atlas_init_static_html_path: Path | None = None
57
58
 
58
59
  atlas_init_cfn_profile: str = ""
59
60
  atlas_init_cfn_region: str = ""
@@ -136,6 +137,17 @@ class AtlasInitSettings(StaticSettings):
136
137
  def github_ci_summary_dir(self) -> Path:
137
138
  return self.cache_root / "github_ci_summary"
138
139
 
140
+ def github_ci_summary_path(self, summary_name: str) -> Path:
141
+ return self.github_ci_summary_dir / str_utils.ensure_suffix(summary_name, ".md")
142
+
143
+ def github_ci_summary_details_path(self, summary_name: str, test_name: str) -> Path:
144
+ return self.github_ci_summary_path(summary_name).parent / self.github_ci_summary_details_rel_path(
145
+ summary_name, test_name
146
+ )
147
+
148
+ def github_ci_summary_details_rel_path(self, summary_name: str, test_name: str) -> str:
149
+ return f"{summary_name.removesuffix('.md')}_details/{test_name}.md"
150
+
139
151
  @property
140
152
  def go_test_logs_dir(self) -> Path:
141
153
  return self.cache_root / "go_test_logs"
@@ -231,7 +243,9 @@ def detect_ambiguous_env_vars(manual_env_vars: dict[str, str]) -> list[str]:
231
243
 
232
244
  def find_missing_env_vars(required_env_vars: list[str], manual_env_vars: dict[str, str]) -> list[str]:
233
245
  return sorted(
234
- env_name for env_name in required_env_vars if read_from_env(env_name) == "" and env_name not in manual_env_vars
246
+ env_name
247
+ for env_name in required_env_vars
248
+ if read_from_env(env_name) == "" and env_name not in manual_env_vars and env_name
235
249
  )
236
250
 
237
251
 
@@ -243,7 +257,15 @@ def init_settings(
243
257
  profile_env_vars = settings.manual_env_vars
244
258
  vscode_env_vars = settings.env_vars_vs_code
245
259
  if vscode_env_vars.exists():
246
- profile_env_vars |= load_dotenv(vscode_env_vars)
260
+ skip_generated_vars: set[str] = set()
261
+ if "AWS_PROFILE" in profile_env_vars:
262
+ skip_generated_vars |= {
263
+ "AWS_ACCESS_KEY_ID",
264
+ "AWS_SECRET_ACCESS_KEY",
265
+ } # avoid generated env-vars overwriting AWS PROFILE
266
+ profile_env_vars |= {
267
+ key: value for key, value in load_dotenv(vscode_env_vars).items() if key not in skip_generated_vars
268
+ }
247
269
  required_env_vars = collect_required_env_vars(list(settings_classes))
248
270
  ambiguous = [] if skip_ambiguous_check else detect_ambiguous_env_vars(profile_env_vars)
249
271
  missing_env_vars = find_missing_env_vars(required_env_vars, profile_env_vars)
@@ -63,3 +63,5 @@ class RealmSettings(_EnvVarsGenerated):
63
63
 
64
64
  class AtlasSettingsWithProject(AtlasSettings):
65
65
  MONGODB_ATLAS_PROJECT_ID: str
66
+ MONGODB_ATLAS_PROJECT_OWNER_ID: str = ""
67
+ MONGODB_ATLAS_USER_EMAIL: str = ""