atlas-init 0.4.5__py3-none-any.whl → 0.6.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 (63) hide show
  1. atlas_init/__init__.py +1 -1
  2. atlas_init/cli.py +2 -0
  3. atlas_init/cli_cfn/cfn_parameter_finder.py +59 -51
  4. atlas_init/cli_cfn/example.py +8 -16
  5. atlas_init/cli_helper/go.py +6 -10
  6. atlas_init/cli_root/mms_released.py +46 -0
  7. atlas_init/cli_tf/app.py +3 -84
  8. atlas_init/cli_tf/ci_tests.py +493 -0
  9. atlas_init/cli_tf/codegen/__init__.py +0 -0
  10. atlas_init/cli_tf/codegen/models.py +97 -0
  11. atlas_init/cli_tf/codegen/openapi_minimal.py +74 -0
  12. atlas_init/cli_tf/github_logs.py +7 -94
  13. atlas_init/cli_tf/go_test_run.py +385 -132
  14. atlas_init/cli_tf/go_test_summary.py +331 -4
  15. atlas_init/cli_tf/go_test_tf_error.py +380 -0
  16. atlas_init/cli_tf/hcl/modifier.py +14 -12
  17. atlas_init/cli_tf/hcl/modifier2.py +87 -0
  18. atlas_init/cli_tf/mock_tf_log.py +1 -1
  19. atlas_init/cli_tf/{schema_v2_api_parsing.py → openapi.py} +95 -17
  20. atlas_init/cli_tf/schema_v2.py +43 -1
  21. atlas_init/crud/__init__.py +0 -0
  22. atlas_init/crud/mongo_client.py +115 -0
  23. atlas_init/crud/mongo_dao.py +296 -0
  24. atlas_init/crud/mongo_utils.py +239 -0
  25. atlas_init/repos/go_sdk.py +12 -3
  26. atlas_init/repos/path.py +110 -7
  27. atlas_init/settings/config.py +3 -6
  28. atlas_init/settings/env_vars.py +5 -1
  29. atlas_init/settings/interactive2.py +134 -0
  30. atlas_init/tf/.terraform.lock.hcl +59 -59
  31. atlas_init/tf/always.tf +5 -5
  32. atlas_init/tf/main.tf +3 -3
  33. atlas_init/tf/modules/aws_kms/aws_kms.tf +1 -1
  34. atlas_init/tf/modules/aws_s3/provider.tf +2 -1
  35. atlas_init/tf/modules/aws_vpc/provider.tf +2 -1
  36. atlas_init/tf/modules/cfn/cfn.tf +0 -8
  37. atlas_init/tf/modules/cfn/kms.tf +5 -5
  38. atlas_init/tf/modules/cfn/provider.tf +7 -0
  39. atlas_init/tf/modules/cfn/variables.tf +1 -1
  40. atlas_init/tf/modules/cloud_provider/cloud_provider.tf +1 -1
  41. atlas_init/tf/modules/cloud_provider/provider.tf +2 -1
  42. atlas_init/tf/modules/cluster/cluster.tf +31 -31
  43. atlas_init/tf/modules/cluster/provider.tf +2 -1
  44. atlas_init/tf/modules/encryption_at_rest/provider.tf +2 -1
  45. atlas_init/tf/modules/federated_vars/federated_vars.tf +1 -1
  46. atlas_init/tf/modules/federated_vars/provider.tf +2 -1
  47. atlas_init/tf/modules/project_extra/project_extra.tf +1 -10
  48. atlas_init/tf/modules/project_extra/provider.tf +8 -0
  49. atlas_init/tf/modules/stream_instance/provider.tf +8 -0
  50. atlas_init/tf/modules/stream_instance/stream_instance.tf +0 -9
  51. atlas_init/tf/modules/vpc_peering/provider.tf +10 -0
  52. atlas_init/tf/modules/vpc_peering/vpc_peering.tf +0 -10
  53. atlas_init/tf/modules/vpc_privatelink/versions.tf +2 -1
  54. atlas_init/tf/outputs.tf +1 -0
  55. atlas_init/tf/providers.tf +1 -1
  56. atlas_init/tf/variables.tf +7 -7
  57. atlas_init/typer_app.py +4 -8
  58. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/METADATA +7 -4
  59. atlas_init-0.6.0.dist-info/RECORD +121 -0
  60. atlas_init-0.4.5.dist-info/RECORD +0 -105
  61. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/WHEEL +0 -0
  62. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/entry_points.txt +0 -0
  63. {atlas_init-0.4.5.dist-info → atlas_init-0.6.0.dist-info}/licenses/LICENSE +0 -0
@@ -5,7 +5,7 @@ import re
5
5
  from collections.abc import Iterable
6
6
  from pathlib import Path
7
7
  from queue import Queue
8
- from typing import ClassVar
8
+ from typing import ClassVar, NamedTuple
9
9
 
10
10
  from model_lib import Entity, dump
11
11
  from pydantic import Field
@@ -48,6 +48,21 @@ def parse_openapi_schema_after_modifications(schema: SchemaV2, api_spec_path: Pa
48
48
  return api_spec_text_changes(schema, original)
49
49
 
50
50
 
51
+ class PathMethodCode(NamedTuple):
52
+ path: str
53
+ method: str
54
+ code: str
55
+
56
+
57
+ def extract_api_version_content_header(header: str) -> str | None:
58
+ """
59
+ Extracts the API version from the content header.
60
+ The header should be in the format 'application/vnd.atlas.v1+json'.
61
+ """
62
+ match = re.match(r"application/vnd\.atlas\.v?(?P<version>[\d-]+)\+json", header)
63
+ return match.group("version") if match else None
64
+
65
+
51
66
  class OpenapiSchema(Entity):
52
67
  PARAMETERS_PREFIX: ClassVar[str] = "#/components/parameters/"
53
68
  SCHEMAS_PREFIX: ClassVar[str] = "#/components/schemas/"
@@ -64,13 +79,32 @@ class OpenapiSchema(Entity):
64
79
  def read_method(self, path: str) -> dict | None:
65
80
  return self.paths.get(path, {}).get("get")
66
81
 
82
+ def delete_method(self, path: str) -> dict | None:
83
+ return self.paths.get(path, {}).get("delete")
84
+
85
+ def patch_method(self, path: str) -> dict | None:
86
+ return self.paths.get(path, {}).get("patch")
87
+
88
+ def put_method(self, path: str) -> dict | None:
89
+ return self.paths.get(path, {}).get("patch")
90
+
91
+ def methods(self, path: str) -> Iterable[dict]:
92
+ for method_name in ["post", "get", "delete", "patch", "put"]:
93
+ if method := self.paths.get(path, {}).get(method_name):
94
+ yield method
95
+
67
96
  def method_refs(self, path: str) -> Iterable[str]:
68
- for method in [self.create_method(path), self.read_method(path)]:
97
+ for method in self.methods(path):
69
98
  if method:
70
- if req_ref := self.method_request_body_ref(method):
71
- yield req_ref
72
- if resp_ref := self.method_response_ref(method):
73
- yield resp_ref
99
+ yield from self.method_request_body_ref(method)
100
+ yield from self.method_response_ref(method)
101
+
102
+ def parameter_refs(self, path: str) -> Iterable[str]:
103
+ for method in self.methods(path):
104
+ parameters = method.get("parameters", [])
105
+ for param in parameters:
106
+ if param_ref := param.get("$ref"):
107
+ yield param_ref
74
108
 
75
109
  def parameter(self, ref: str) -> dict:
76
110
  assert ref.startswith(OpenapiSchema.PARAMETERS_PREFIX)
@@ -91,23 +125,50 @@ class OpenapiSchema(Entity):
91
125
  prop["name"] = name
92
126
  yield prop
93
127
 
94
- def method_request_body_ref(self, method: dict) -> str | None:
128
+ def method_request_body_ref(self, method: dict) -> Iterable[str]:
95
129
  request_body = method.get("requestBody", {})
96
- return self._unpack_schema_ref(request_body)
130
+ yield from self._unpack_schema_ref(request_body)
97
131
 
98
- def method_response_ref(self, method: dict) -> str | None:
132
+ def method_response_ref(self, method: dict) -> Iterable[str]:
99
133
  responses = method.get("responses", {})
100
134
  ok_response = responses.get("200", {})
101
- return self._unpack_schema_ref(ok_response)
135
+ yield from self._unpack_schema_ref(ok_response)
102
136
 
103
- def _unpack_schema_ref(self, response: dict) -> str | None:
137
+ def _unpack_schema_ref(self, response: dict) -> Iterable[str]:
104
138
  content = {**response.get("content", {})} # avoid side effects
105
139
  if not content:
106
140
  return None
107
- key, value = content.popitem()
108
- if not isinstance(key, str) or not key.endswith("json"):
109
- return None
110
- return value.get("schema", {}).get("$ref")
141
+ while content:
142
+ key, value = content.popitem()
143
+ if not isinstance(key, str) or not key.endswith("json"):
144
+ continue
145
+ if ref := value.get("schema", {}).get("$ref"):
146
+ yield ref
147
+
148
+ def _unpack_schema_versions(self, response: dict) -> list[str]:
149
+ content: dict[str, dict] = {**response.get("content", {})}
150
+ versions = []
151
+ while content:
152
+ key, value = content.popitem()
153
+ if not isinstance(value, dict) or not key.endswith("json"):
154
+ continue
155
+ if version := value.get("x-xgen-version"):
156
+ versions.append(version)
157
+ continue
158
+ if version := extract_api_version_content_header(key):
159
+ versions.append(version)
160
+ return versions
161
+
162
+ def path_method_api_versions(self) -> Iterable[tuple[PathMethodCode, list[str]]]:
163
+ for path, methods in self.paths.items():
164
+ for method_name, method_dict in methods.items():
165
+ if not isinstance(method_dict, dict):
166
+ continue
167
+ responses = method_dict.get("responses", {})
168
+ for code, response_dict in responses.items():
169
+ if api_versions := self._unpack_schema_versions(response_dict):
170
+ key = PathMethodCode(path, method_name, code)
171
+ yield key, api_versions
111
172
 
112
173
  def schema_ref_component(self, ref: str, attributes_skip: set[str]) -> SchemaResource:
113
174
  schemas = self.components.get("schemas", {})
@@ -128,6 +189,9 @@ class OpenapiSchema(Entity):
128
189
  name=ref,
129
190
  description=schema.get("description", ""),
130
191
  attributes_skip=attributes_skip,
192
+ discriminator=schema.get("discriminator"),
193
+ one_of=schema.get("oneOf", []),
194
+ all_of=schema.get("allOf", []),
131
195
  )
132
196
  required_names = schema.get("required", [])
133
197
  for prop in self.schema_properties(ref):
@@ -143,6 +207,16 @@ class OpenapiSchema(Entity):
143
207
  elif ref.startswith(self.SCHEMAS_PREFIX):
144
208
  prefix = self.SCHEMAS_PREFIX
145
209
  parent_dict = self.components["schemas"]
210
+ ref_value.pop("name", None)
211
+ if properties := ref_value.get("properties"):
212
+ properties_no_name = {
213
+ k: {nested_k: nested_v for nested_k, nested_v in v.items() if nested_k != "name"}
214
+ for k, v in properties.items()
215
+ }
216
+ if ref.removeprefix(prefix).endswith("DBRoleToExecute"):
217
+ logger.warning(f"debug me: {properties_no_name}")
218
+ ref_value["properties"] = properties_no_name
219
+
146
220
  else:
147
221
  err_msg = f"Unknown schema_ref {ref}"
148
222
  raise ValueError(err_msg)
@@ -168,12 +242,14 @@ def parse_api_spec_param(api_spec: OpenapiSchema, param: dict, resource: SchemaR
168
242
  case {"$ref": ref, "name": name} if ref.startswith(OpenapiSchema.SCHEMAS_PREFIX):
169
243
  # nested attribute
170
244
  attribute = SchemaAttribute(
245
+ additional_properties=param.get("additionalProperties", {}),
171
246
  type="object",
172
247
  name=name,
173
248
  schema_ref=ref,
174
249
  )
175
250
  case {"type": "array", "items": {"$ref": ref}, "name": name}:
176
251
  attribute = SchemaAttribute(
252
+ additional_properties=param.get("additionalProperties", {}),
177
253
  type="array",
178
254
  name=name,
179
255
  schema_ref=ref,
@@ -183,6 +259,7 @@ def parse_api_spec_param(api_spec: OpenapiSchema, param: dict, resource: SchemaR
183
259
  )
184
260
  case {"name": name, "schema": schema}:
185
261
  attribute = SchemaAttribute(
262
+ additional_properties=param.get("additionalProperties", {}),
186
263
  type=schema["type"],
187
264
  name=name,
188
265
  description=param.get("description", ""),
@@ -196,6 +273,7 @@ def parse_api_spec_param(api_spec: OpenapiSchema, param: dict, resource: SchemaR
196
273
  description=param.get("description", ""),
197
274
  is_computed=param.get("readOnly", False),
198
275
  is_required=param.get("required", False),
276
+ additional_properties=param.get("additionalProperties", {}),
199
277
  )
200
278
  case _:
201
279
  raise NotImplementedError
@@ -220,7 +298,7 @@ def add_api_spec_info(schema: SchemaV2, api_spec_path: Path, *, minimal_refs: bo
220
298
  continue
221
299
  for param in create_method.get("parameters", []):
222
300
  parse_api_spec_param(api_spec, param, resource)
223
- if req_ref := api_spec.method_request_body_ref(create_method):
301
+ for req_ref in api_spec.method_request_body_ref(create_method):
224
302
  for property_dict in api_spec.schema_properties(req_ref):
225
303
  parse_api_spec_param(api_spec, property_dict, resource)
226
304
  for path in resource.paths:
@@ -229,7 +307,7 @@ def add_api_spec_info(schema: SchemaV2, api_spec_path: Path, *, minimal_refs: bo
229
307
  continue
230
308
  for param in read_method.get("parameters", []):
231
309
  parse_api_spec_param(api_spec, param, resource)
232
- if response_ref := api_spec.method_response_ref(read_method):
310
+ for response_ref in api_spec.method_response_ref(read_method):
233
311
  for property_dict in api_spec.schema_properties(response_ref):
234
312
  parse_api_spec_param(api_spec, property_dict, resource)
235
313
  if minimal_refs:
@@ -8,11 +8,12 @@ from fnmatch import fnmatch
8
8
  from pathlib import Path
9
9
  from queue import Queue
10
10
  from tempfile import TemporaryDirectory
11
- from typing import Literal, TypeAlias
11
+ from typing import Any, Literal, TypeAlias
12
12
 
13
13
  from model_lib import Entity, copy_and_validate, parse_model
14
14
  from pydantic import ConfigDict, Field, model_validator
15
15
  from zero_3rdparty.enum_utils import StrEnum
16
+ from zero_3rdparty.iter_utils import flat_map
16
17
 
17
18
  from atlas_init.cli_helper.run import run_binary_command_is_ok
18
19
  from atlas_init.humps import decamelize, pascalize
@@ -50,6 +51,13 @@ class SchemaAttribute(Entity):
50
51
  validators: list[SchemaAttributeValidator] = Field(default_factory=list)
51
52
  # not used during dumping but backtrace which parameters are used in the api spec
52
53
  parameter_ref: str = ""
54
+ additional_properties: dict[str, Any] = Field(default_factory=dict)
55
+
56
+ @property
57
+ def additional_properties_ref(self) -> str:
58
+ if props := self.additional_properties:
59
+ return props.get("$ref", "")
60
+ return ""
53
61
 
54
62
  @property
55
63
  def tf_name(self) -> str:
@@ -88,6 +96,7 @@ class SchemaAttribute(Entity):
88
96
  plan_modifiers=self.plan_modifiers + other.plan_modifiers,
89
97
  validators=self.validators + other.validators,
90
98
  parameter_ref=self.parameter_ref or other.parameter_ref,
99
+ additional_properties=self.additional_properties | other.additional_properties,
91
100
  )
92
101
 
93
102
  def set_attribute_type(
@@ -175,6 +184,29 @@ class SDKConversion(Entity):
175
184
  return bool(self.sdk_start_refs)
176
185
 
177
186
 
187
+ class Discriminator(Entity):
188
+ mapping: dict[str, str] = Field(default_factory=dict)
189
+ property_name: str = Field(alias="propertyName")
190
+
191
+
192
+ class OneOf(Entity):
193
+ ref: str = Field(alias="$ref", default="")
194
+
195
+
196
+ class AllOf(Entity):
197
+ ref: str = Field(alias="$ref", default="")
198
+ properties: dict[str, Any] = Field(default_factory=dict)
199
+
200
+ @property
201
+ def nested_refs(self) -> set[str]:
202
+ refs = set()
203
+ for prop, prop_value in self.properties.items():
204
+ if isinstance(prop_value, dict):
205
+ if ref := prop_value.get("$ref"):
206
+ refs.add(ref)
207
+ return refs
208
+
209
+
178
210
  class SchemaResource(Entity):
179
211
  name: str = "" # populated by the key of the resources dict
180
212
  description: str = ""
@@ -183,6 +215,16 @@ class SchemaResource(Entity):
183
215
  paths: list[str] = Field(default_factory=list)
184
216
  attribute_type_modifiers: AttributeTypeModifiers = Field(default_factory=AttributeTypeModifiers)
185
217
  conversion: SDKConversion = Field(default_factory=SDKConversion)
218
+ discriminator: Discriminator | None = None
219
+ one_of: list[OneOf] = Field(default_factory=list)
220
+ all_of: list[AllOf] = Field(default_factory=list)
221
+
222
+ def extra_refs(self) -> set[str]:
223
+ return (
224
+ {one_of.ref for one_of in self.one_of if one_of.ref}
225
+ | {all_of.ref for all_of in self.all_of if all_of.ref}
226
+ | {ref for ref in flat_map(all_of.nested_refs for all_of in self.all_of) if ref}
227
+ )
186
228
 
187
229
  @model_validator(mode="after")
188
230
  def set_attribute_names(self):
File without changes
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import logging
5
+ from typing import TypeAlias
6
+
7
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorCollection
8
+ from pymongo import IndexModel
9
+ from pymongo.errors import DuplicateKeyError
10
+ from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
11
+
12
+ from atlas_init.cli_tf.go_test_run import GoTestRun
13
+ from atlas_init.cli_tf.go_test_tf_error import GoTestErrorClassification
14
+ from atlas_init.crud.mongo_utils import index_dec
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class CollectionConfig:
22
+ name: str = "" # uses the class name by default
23
+ indexes: list[IndexModel] = field(default_factory=list)
24
+
25
+
26
+ CollectionConfigsT: TypeAlias = dict[type, CollectionConfig]
27
+
28
+
29
+ def default_document_models() -> CollectionConfigsT:
30
+ return {
31
+ GoTestErrorClassification: CollectionConfig(
32
+ indexes=[index_dec("ts"), IndexModel(["error_class"]), IndexModel(["test_name"])]
33
+ ),
34
+ GoTestRun: CollectionConfig(indexes=[index_dec("ts"), IndexModel(["branch"]), IndexModel(["status"])]),
35
+ }
36
+
37
+
38
+ _collections = {}
39
+
40
+
41
+ def get_collection(model: type) -> AsyncIOMotorCollection:
42
+ col = _collections.get(model)
43
+ if col is not None:
44
+ return col
45
+ raise ValueError(f"Collection for model {model.__name__} is not initialized. Call init_mongo first.")
46
+
47
+
48
+ def get_db(mongo_url: str, db_name: str) -> AsyncIOMotorDatabase:
49
+ client = AsyncIOMotorClient(mongo_url)
50
+ return client.get_database(db_name)
51
+
52
+
53
+ async def init_mongo(
54
+ mongo_url: str, db_name: str, clean_collections: bool = False, document_models: CollectionConfigsT | None = None
55
+ ) -> None:
56
+ db = get_db(mongo_url, db_name)
57
+ document_models = document_models or default_document_models()
58
+ for model, cfg in document_models.items():
59
+ name = cfg.name or model.__name__
60
+ col = await ensure_collection_exist(db, name, cfg.indexes, clean_collections)
61
+ _collections[model] = col
62
+
63
+ if clean_collections:
64
+ logger.info(f"MongoDB collections in '{db_name}' have been cleaned.")
65
+
66
+
67
+ async def ensure_collection_exist(
68
+ db: AsyncIOMotorDatabase,
69
+ name: str,
70
+ indexes: list[IndexModel] | None = None,
71
+ clean_collection: bool = False,
72
+ ) -> AsyncIOMotorCollection:
73
+ existing = await db.list_collection_names()
74
+ if clean_collection and name in existing:
75
+ await db.drop_collection(name)
76
+ existing.remove(name)
77
+
78
+ if name not in existing:
79
+ await db.create_collection(name)
80
+
81
+ if indexes:
82
+ # always (re-)create indexes after new creation or drop
83
+ await db[name].create_indexes(indexes)
84
+
85
+ logger.debug(f"mongo collection {name!r} is ready")
86
+ return db[name]
87
+
88
+
89
+ def duplicate_key_pattern(error: DuplicateKeyError) -> str | None:
90
+ details: dict = error.details # type: ignore
91
+ name_violator = details.get("keyPattern", {})
92
+ if not name_violator:
93
+ return None
94
+ name, _ = name_violator.popitem()
95
+ return name
96
+
97
+
98
+ class CollectionNotEmptyError(Exception):
99
+ def __init__(self, collection_name: str):
100
+ super().__init__(f"Collection '{collection_name}' is not empty.")
101
+ self.collection_name = collection_name
102
+
103
+
104
+ @retry(
105
+ stop=stop_after_attempt(10),
106
+ wait=wait_fixed(0.5),
107
+ retry=retry_if_exception_type(CollectionNotEmptyError),
108
+ reraise=True,
109
+ )
110
+ async def _empty_collections() -> None:
111
+ col: AsyncIOMotorCollection
112
+ for col in _collections.values():
113
+ count = await col.count_documents({})
114
+ if count > 0:
115
+ raise CollectionNotEmptyError(col.name)