atlas-init 0.4.5__py3-none-any.whl → 0.7.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 (83) hide show
  1. atlas_init/__init__.py +1 -1
  2. atlas_init/cli.py +2 -0
  3. atlas_init/cli_args.py +19 -1
  4. atlas_init/cli_cfn/cfn_parameter_finder.py +59 -51
  5. atlas_init/cli_cfn/example.py +8 -16
  6. atlas_init/cli_helper/go.py +6 -10
  7. atlas_init/cli_root/mms_released.py +46 -0
  8. atlas_init/cli_tf/app.py +3 -84
  9. atlas_init/cli_tf/ci_tests.py +585 -0
  10. atlas_init/cli_tf/codegen/__init__.py +0 -0
  11. atlas_init/cli_tf/codegen/models.py +97 -0
  12. atlas_init/cli_tf/codegen/openapi_minimal.py +74 -0
  13. atlas_init/cli_tf/github_logs.py +7 -94
  14. atlas_init/cli_tf/go_test_run.py +395 -130
  15. atlas_init/cli_tf/go_test_summary.py +589 -10
  16. atlas_init/cli_tf/go_test_tf_error.py +388 -0
  17. atlas_init/cli_tf/hcl/modifier.py +14 -12
  18. atlas_init/cli_tf/hcl/modifier2.py +207 -0
  19. atlas_init/cli_tf/mock_tf_log.py +1 -1
  20. atlas_init/cli_tf/{schema_v2_api_parsing.py → openapi.py} +101 -19
  21. atlas_init/cli_tf/schema_v2.py +43 -1
  22. atlas_init/crud/__init__.py +0 -0
  23. atlas_init/crud/mongo_client.py +115 -0
  24. atlas_init/crud/mongo_dao.py +296 -0
  25. atlas_init/crud/mongo_utils.py +239 -0
  26. atlas_init/html_out/__init__.py +0 -0
  27. atlas_init/html_out/md_export.py +143 -0
  28. atlas_init/repos/go_sdk.py +12 -3
  29. atlas_init/repos/path.py +110 -7
  30. atlas_init/sdk_ext/__init__.py +0 -0
  31. atlas_init/sdk_ext/go.py +102 -0
  32. atlas_init/sdk_ext/typer_app.py +18 -0
  33. atlas_init/settings/config.py +3 -6
  34. atlas_init/settings/env_vars.py +18 -2
  35. atlas_init/settings/env_vars_generated.py +2 -0
  36. atlas_init/settings/interactive2.py +134 -0
  37. atlas_init/tf/.terraform.lock.hcl +59 -59
  38. atlas_init/tf/always.tf +5 -5
  39. atlas_init/tf/main.tf +3 -3
  40. atlas_init/tf/modules/aws_kms/aws_kms.tf +1 -1
  41. atlas_init/tf/modules/aws_s3/provider.tf +2 -1
  42. atlas_init/tf/modules/aws_vpc/provider.tf +2 -1
  43. atlas_init/tf/modules/cfn/cfn.tf +0 -8
  44. atlas_init/tf/modules/cfn/kms.tf +5 -5
  45. atlas_init/tf/modules/cfn/provider.tf +7 -0
  46. atlas_init/tf/modules/cfn/variables.tf +1 -1
  47. atlas_init/tf/modules/cloud_provider/cloud_provider.tf +1 -1
  48. atlas_init/tf/modules/cloud_provider/provider.tf +2 -1
  49. atlas_init/tf/modules/cluster/cluster.tf +31 -31
  50. atlas_init/tf/modules/cluster/provider.tf +2 -1
  51. atlas_init/tf/modules/encryption_at_rest/provider.tf +2 -1
  52. atlas_init/tf/modules/federated_vars/federated_vars.tf +2 -3
  53. atlas_init/tf/modules/federated_vars/provider.tf +2 -1
  54. atlas_init/tf/modules/project_extra/project_extra.tf +1 -10
  55. atlas_init/tf/modules/project_extra/provider.tf +8 -0
  56. atlas_init/tf/modules/stream_instance/provider.tf +8 -0
  57. atlas_init/tf/modules/stream_instance/stream_instance.tf +0 -9
  58. atlas_init/tf/modules/vpc_peering/provider.tf +10 -0
  59. atlas_init/tf/modules/vpc_peering/vpc_peering.tf +0 -10
  60. atlas_init/tf/modules/vpc_privatelink/versions.tf +2 -1
  61. atlas_init/tf/outputs.tf +1 -0
  62. atlas_init/tf/providers.tf +1 -1
  63. atlas_init/tf/variables.tf +7 -7
  64. atlas_init/tf_ext/__init__.py +0 -0
  65. atlas_init/tf_ext/__main__.py +3 -0
  66. atlas_init/tf_ext/api_call.py +325 -0
  67. atlas_init/tf_ext/args.py +17 -0
  68. atlas_init/tf_ext/constants.py +3 -0
  69. atlas_init/tf_ext/models.py +106 -0
  70. atlas_init/tf_ext/paths.py +126 -0
  71. atlas_init/tf_ext/settings.py +39 -0
  72. atlas_init/tf_ext/tf_dep.py +324 -0
  73. atlas_init/tf_ext/tf_modules.py +394 -0
  74. atlas_init/tf_ext/tf_vars.py +173 -0
  75. atlas_init/tf_ext/typer_app.py +24 -0
  76. atlas_init/typer_app.py +4 -8
  77. {atlas_init-0.4.5.dist-info → atlas_init-0.7.0.dist-info}/METADATA +8 -4
  78. atlas_init-0.7.0.dist-info/RECORD +138 -0
  79. atlas_init-0.7.0.dist-info/entry_points.txt +5 -0
  80. atlas_init-0.4.5.dist-info/RECORD +0 -105
  81. atlas_init-0.4.5.dist-info/entry_points.txt +0 -2
  82. {atlas_init-0.4.5.dist-info → atlas_init-0.7.0.dist-info}/WHEEL +0 -0
  83. {atlas_init-0.4.5.dist-info → atlas_init-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,585 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import re
7
+ from concurrent.futures import Future, ThreadPoolExecutor, wait
8
+ from datetime import date, datetime, timedelta
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from ask_shell import confirm, new_task, print_to_live, run_and_wait, select_list
13
+ from model_lib import Entity, Event, copy_and_validate
14
+ from pydantic import Field, ValidationError, field_validator, model_validator
15
+ from pydantic_core import Url
16
+ from rich.markdown import Markdown
17
+ from zero_3rdparty import file_utils
18
+ from zero_3rdparty.datetime_utils import utc_now
19
+ from zero_3rdparty.str_utils import ensure_suffix
20
+
21
+ from atlas_init.cli_helper.run import add_to_clipboard
22
+ from atlas_init.cli_tf.github_logs import (
23
+ GH_TOKEN_ENV_NAME,
24
+ download_job_safely,
25
+ is_test_job,
26
+ tf_repo,
27
+ )
28
+ from atlas_init.cli_tf.go_test_run import GoTestRun, GoTestStatus, parse_tests
29
+ from atlas_init.cli_tf.go_test_summary import (
30
+ DailyReportIn,
31
+ DailyReportOut,
32
+ ErrorRowColumns,
33
+ MonthlyReportIn,
34
+ RunHistoryFilter,
35
+ TFCITestOutput,
36
+ TestRow,
37
+ create_daily_report,
38
+ create_monthly_report,
39
+ )
40
+ from atlas_init.cli_tf.go_test_tf_error import (
41
+ DetailsInfo,
42
+ ErrorClassAuthor,
43
+ GoTestError,
44
+ GoTestErrorClass,
45
+ GoTestErrorClassification,
46
+ parse_error_details,
47
+ )
48
+ from atlas_init.cli_tf.mock_tf_log import resolve_admin_api_path
49
+ from atlas_init.crud.mongo_dao import (
50
+ TFResources,
51
+ init_mongo_dao,
52
+ read_tf_resources,
53
+ )
54
+ from atlas_init.html_out.md_export import MonthlyReportPaths, export_ci_tests_markdown_to_html
55
+ from atlas_init.repos.go_sdk import ApiSpecPaths, parse_api_spec_paths
56
+ from atlas_init.repos.path import Repo, current_repo_path
57
+ from atlas_init.settings.env_vars import AtlasInitSettings, init_settings
58
+
59
+ logger = logging.getLogger(__name__)
60
+
61
+
62
+ class TFCITestInput(Event):
63
+ settings: AtlasInitSettings = Field(default_factory=init_settings)
64
+ repo_path: Path = Field(default_factory=lambda: current_repo_path(Repo.TF))
65
+ test_group_name: str = ""
66
+ max_days_ago: int = 1
67
+ branch: str = "master"
68
+ workflow_file_stems: set[str] = Field(default_factory=lambda: set(_TEST_STEMS))
69
+ names: set[str] = Field(default_factory=set)
70
+ skip_log_download: bool = False
71
+ skip_error_parsing: bool = False
72
+ summary_name: str = ""
73
+ report_date: datetime = Field(default_factory=utc_now)
74
+
75
+ @field_validator("report_date", mode="before")
76
+ def support_today(cls, value: str | datetime) -> datetime | str:
77
+ return utc_now() if isinstance(value, str) and value == "today" else value
78
+
79
+ @model_validator(mode="after")
80
+ def set_workflow_file_stems(self) -> TFCITestInput:
81
+ if not self.workflow_file_stems:
82
+ self.workflow_file_stems = set(_TEST_STEMS)
83
+ return self
84
+
85
+
86
+ def ci_tests(
87
+ test_group_name: str = typer.Option("", "-g"),
88
+ max_days_ago: int = typer.Option(
89
+ 1, "-d", "--days", help="number of days to look back, Github only store logs for 30 days."
90
+ ),
91
+ branch: str = typer.Option("master", "-b", "--branch"),
92
+ workflow_file_stems: str = typer.Option("test-suite,terraform-compatibility-matrix", "-w", "--workflow"),
93
+ names: str = typer.Option(
94
+ "",
95
+ "-n",
96
+ "--test-names",
97
+ help="comma separated list of test names to filter, e.g., TestAccCloudProviderAccessAuthorizationAzure_basic,TestAccBackupSnapshotExportBucket_basicAzure",
98
+ ),
99
+ summary_name: str = typer.Option(
100
+ ...,
101
+ "-s",
102
+ "--summary",
103
+ help="the name of the summary directory to store detailed test results",
104
+ default_factory=lambda: utc_now().strftime("%Y-%m-%d"),
105
+ ),
106
+ summary_env_name: str = typer.Option("", "--env", help="filter summary based on tests/errors only in dev/qa"),
107
+ skip_log_download: bool = typer.Option(False, "-sld", "--skip-log-download", help="skip downloading logs"),
108
+ skip_error_parsing: bool = typer.Option(
109
+ False, "-sep", "--skip-error-parsing", help="skip parsing errors, usually together with --skip-log-download"
110
+ ),
111
+ skip_daily: bool = typer.Option(False, "-sd", "--skip-daily", help="skip daily report"),
112
+ skip_monthly: bool = typer.Option(False, "-sm", "--skip-monthly", help="skip monthly report"),
113
+ ask_to_open: bool = typer.Option(False, "--open", "--ask-to-open", help="ask to open the reports"),
114
+ copy_to_clipboard: bool = typer.Option(
115
+ False,
116
+ "--copy",
117
+ help="copy the summary to clipboard",
118
+ ),
119
+ report_date: str = typer.Option(
120
+ "today",
121
+ "-rd",
122
+ "--report-day",
123
+ help="the day to generate the report for, defaults to today, format=YYYY-MM-DD",
124
+ ),
125
+ ):
126
+ names_set: set[str] = set()
127
+ if names:
128
+ names_set.update(names.split(","))
129
+ logger.info(f"filtering tests by names: {names_set} (todo: support this)")
130
+ if test_group_name:
131
+ logger.warning(f"test_group_name is not supported yet: {test_group_name}")
132
+ event = TFCITestInput(
133
+ test_group_name=test_group_name,
134
+ max_days_ago=max_days_ago,
135
+ report_date=report_date, # type: ignore
136
+ branch=branch,
137
+ workflow_file_stems=set(workflow_file_stems.split(",")),
138
+ names=names_set,
139
+ summary_name=summary_name,
140
+ skip_log_download=skip_log_download,
141
+ skip_error_parsing=skip_error_parsing,
142
+ )
143
+ history_filter = RunHistoryFilter(
144
+ run_history_start=event.report_date - timedelta(days=event.max_days_ago),
145
+ run_history_end=event.report_date,
146
+ env_filter=[summary_env_name] if summary_env_name else [],
147
+ )
148
+ settings = event.settings
149
+ report_paths = MonthlyReportPaths.from_settings(settings, summary_name)
150
+ if skip_daily:
151
+ logger.info("skipping daily report")
152
+ else:
153
+ run_daily_report(event, settings, history_filter, copy_to_clipboard, report_paths)
154
+ if summary_name.lower() != "none":
155
+ monthly_input = MonthlyReportIn(
156
+ name=summary_name,
157
+ branch=event.branch,
158
+ history_filter=history_filter,
159
+ report_paths=report_paths,
160
+ )
161
+ if skip_monthly:
162
+ logger.info("skipping monthly report")
163
+ else:
164
+ generate_monthly_summary(settings, monthly_input, ask_to_open)
165
+ export_ci_tests_markdown_to_html(settings, report_paths)
166
+
167
+
168
+ def run_daily_report(
169
+ event: TFCITestInput,
170
+ settings: AtlasInitSettings,
171
+ history_filter: RunHistoryFilter,
172
+ copy_to_clipboard: bool,
173
+ report_paths: MonthlyReportPaths,
174
+ ) -> DailyReportOut:
175
+ out = asyncio.run(ci_tests_pipeline(event))
176
+ manual_classification(out.classified_errors, settings)
177
+ summary_name = event.summary_name
178
+
179
+ def add_md_link(row: TestRow, row_dict: dict[str, str]) -> dict[str, str]:
180
+ if not summary_name:
181
+ return row_dict
182
+ old_details = row_dict[ErrorRowColumns.DETAILS_SUMMARY]
183
+ old_details = old_details or "Test History"
184
+ row_dict[ErrorRowColumns.DETAILS_SUMMARY] = (
185
+ f"[{old_details}]({settings.github_ci_summary_details_rel_path(summary_name, row.full_name)})"
186
+ )
187
+ return row_dict
188
+
189
+ daily_in = DailyReportIn(
190
+ report_date=event.report_date,
191
+ history_filter=history_filter,
192
+ row_modifier=add_md_link,
193
+ )
194
+ daily_out = create_daily_report(out, settings, daily_in)
195
+ print_to_live(Markdown(daily_out.summary_md))
196
+ if copy_to_clipboard:
197
+ add_to_clipboard(daily_out.summary_md, logger=logger)
198
+ file_utils.ensure_parents_write_text(report_paths.daily_path, daily_out.summary_md)
199
+ return daily_out
200
+
201
+
202
+ def generate_monthly_summary(
203
+ settings: AtlasInitSettings, monthly_input: MonthlyReportIn, ask_to_open: bool = False
204
+ ) -> None:
205
+ monthly_out = create_monthly_report(
206
+ settings,
207
+ monthly_input,
208
+ )
209
+ paths = monthly_input.report_paths
210
+ summary_path = paths.summary_path
211
+ file_utils.ensure_parents_write_text(summary_path, monthly_out.summary_md)
212
+ logger.info(f"summary written to {summary_path}")
213
+ details_dir = paths.details_dir
214
+ file_utils.clean_dir(details_dir, recreate=True)
215
+ logger.info(f"Writing details to {details_dir}")
216
+ for name, details_md in monthly_out.test_details_md.items():
217
+ details_path = details_dir / ensure_suffix(name, ".md")
218
+ file_utils.ensure_parents_write_text(details_path, details_md)
219
+ monthly_error_only_out = create_monthly_report(
220
+ settings,
221
+ event=copy_and_validate(
222
+ monthly_input,
223
+ skip_rows=[MonthlyReportIn.skip_if_no_failures],
224
+ existing_details_md=monthly_out.test_details_md,
225
+ ),
226
+ )
227
+ error_only_path = paths.error_only_path
228
+ file_utils.ensure_parents_write_text(error_only_path, monthly_error_only_out.summary_md)
229
+ logger.info(f"error-only summary written to {error_only_path}")
230
+ if ask_to_open and confirm(f"do you want to open the summary file? {summary_path}", default=False):
231
+ run_and_wait(f'code "{summary_path}"')
232
+ if ask_to_open and confirm(f"do you want to open the error-only summary file? {error_only_path}", default=False):
233
+ run_and_wait(f'code "{error_only_path}"')
234
+ return None
235
+
236
+
237
+ async def ci_tests_pipeline(event: TFCITestInput) -> TFCITestOutput:
238
+ repo_path = event.repo_path
239
+ branch = event.branch
240
+ settings = event.settings
241
+ download_input = DownloadJobLogsInput(
242
+ branch=branch,
243
+ max_days_ago=event.max_days_ago,
244
+ end_date=event.report_date,
245
+ workflow_file_stems=event.workflow_file_stems,
246
+ repo_path=repo_path,
247
+ )
248
+ dao = await init_mongo_dao(settings)
249
+ if event.skip_log_download:
250
+ logger.info("skipping log download, reading existing instead")
251
+ log_paths = []
252
+ else:
253
+ log_paths = download_logs(download_input, settings)
254
+ resources = read_tf_resources(settings, repo_path, branch)
255
+ with new_task(f"parse job logs from {len(log_paths)} files"):
256
+ parse_job_output = parse_job_tf_test_logs(
257
+ ParseJobLogsInput(
258
+ settings=settings,
259
+ log_paths=log_paths,
260
+ resources=resources,
261
+ branch=branch,
262
+ )
263
+ )
264
+ await dao.store_tf_test_runs(parse_job_output.test_runs)
265
+ report_date = event.report_date
266
+ with new_task(f"reading test runs from storage for {report_date.date().isoformat()}"):
267
+ report_tests = await dao.read_tf_tests_for_day(event.branch, report_date)
268
+ with new_task("parsing test errors"):
269
+ report_errors = parse_test_errors(report_tests)
270
+ with new_task("classifying errors"):
271
+ error_run_ids = [error.run_id for error in report_errors]
272
+ existing_classifications = await dao.read_error_classifications(error_run_ids)
273
+ classified_errors = classify_errors(existing_classifications, report_errors)
274
+ return TFCITestOutput(
275
+ log_paths=log_paths, found_tests=report_tests, classified_errors=classified_errors, found_errors=report_errors
276
+ )
277
+
278
+
279
+ def parse_test_errors(found_tests: list[GoTestRun]) -> list[GoTestError]:
280
+ admin_api_path = resolve_admin_api_path(sdk_branch="main")
281
+ spec_paths = ApiSpecPaths(method_paths=parse_api_spec_paths(admin_api_path))
282
+ error_tests = [test for test in found_tests if test.is_failure]
283
+ test_errors: list[GoTestError] = []
284
+ for test in error_tests:
285
+ test_error_input = ParseTestErrorInput(test=test, api_spec_paths=spec_paths)
286
+ test_errors.append(parse_test_error(test_error_input))
287
+ return test_errors
288
+
289
+
290
+ def classify_errors(
291
+ existing: dict[str, GoTestErrorClassification], errors: list[GoTestError]
292
+ ) -> list[GoTestErrorClassification]:
293
+ needs_classification: list[GoTestError] = []
294
+ classified_errors: list[GoTestErrorClassification] = []
295
+ for error in errors:
296
+ if prev_classification := existing.get(error.run_id):
297
+ logger.info(f"found existing classification{error.run_name}: {prev_classification}")
298
+ classified_errors.append(prev_classification)
299
+ continue
300
+ if auto_class := GoTestErrorClass.auto_classification(error.run.output_lines_str):
301
+ logger.info(f"auto class for {error.run_name}: {auto_class}")
302
+ classified_errors.append(
303
+ GoTestErrorClassification(
304
+ error_class=auto_class,
305
+ confidence=1.0,
306
+ details=error.details,
307
+ test_output=error.run.output_lines_str,
308
+ run_id=error.run_id,
309
+ author=ErrorClassAuthor.AUTO,
310
+ test_name=error.run_name,
311
+ )
312
+ )
313
+ else:
314
+ needs_classification.append(error)
315
+ return classified_errors + add_llm_classifications(needs_classification)
316
+
317
+
318
+ def manual_classification(
319
+ classifications: list[GoTestErrorClassification], settings: AtlasInitSettings, confidence_threshold: float = 1.0
320
+ ):
321
+ needs_classification = [cls for cls in classifications if cls.needs_classification(confidence_threshold)]
322
+ with new_task("Manual Classification", total=len(needs_classification) + 1, log_updates=True) as task:
323
+ asyncio.run(classify(needs_classification, settings, task))
324
+
325
+
326
+ async def classify(
327
+ needs_classification: list[GoTestErrorClassification], settings: AtlasInitSettings, task: new_task
328
+ ) -> None:
329
+ dao = await init_mongo_dao(settings)
330
+
331
+ async def add_classification(
332
+ cls: GoTestErrorClassification, new_class: GoTestErrorClass, new_author: ErrorClassAuthor, confidence: float
333
+ ):
334
+ cls.error_class = new_class
335
+ cls.author = new_author
336
+ cls.confidence = confidence
337
+ is_new = await dao.add_classification(cls)
338
+ if not is_new:
339
+ logger.debug("replaced existing class")
340
+
341
+ for cls in needs_classification:
342
+ task.update(advance=1)
343
+ similars = await dao.read_similar_error_classifications(cls.details, author_filter=ErrorClassAuthor.HUMAN)
344
+ if (existing := similars.get(cls.run_id)) and not existing.needs_classification():
345
+ logger.debug(f"found existing classification: {existing}")
346
+ continue
347
+ if similars and len({similar.error_class for similar in similars.values()}) == 1:
348
+ _, similar = similars.popitem()
349
+ if not similar.needs_classification(0.0):
350
+ logger.info(f"using similar classification: {similar}")
351
+ await add_classification(cls, similar.error_class, ErrorClassAuthor.SIMILAR, 1.0)
352
+ continue
353
+ test = await dao.read_tf_test_run(cls.run_id)
354
+ if new_class := ask_user_to_classify_error(cls, test):
355
+ await add_classification(cls, new_class, ErrorClassAuthor.HUMAN, 1.0)
356
+ elif confirm("do you want to stop classifying errors?", default=True):
357
+ logger.info("stopping classification")
358
+ return
359
+
360
+
361
+ def add_llm_classifications(needs_classification_errors: list[GoTestError]) -> list[GoTestErrorClassification]:
362
+ """Todo: Use LLM and support reading existing classifications, for example matching on the details"""
363
+ return [
364
+ GoTestErrorClassification(
365
+ ts=utc_now(),
366
+ error_class=GoTestErrorClass.UNKNOWN,
367
+ confidence=0.0,
368
+ details=error.details,
369
+ test_output=error.run.output_lines_str,
370
+ run_id=error.run_id,
371
+ author=ErrorClassAuthor.LLM,
372
+ test_name=error.run_name,
373
+ )
374
+ for error in needs_classification_errors
375
+ ]
376
+
377
+
378
+ class DownloadJobLogsInput(Entity):
379
+ branch: str = "master"
380
+ workflow_file_stems: set[str] = Field(default_factory=lambda: set(_TEST_STEMS))
381
+ max_days_ago: int = 1
382
+ end_date: datetime = Field(default_factory=utc_now)
383
+ repo_path: Path
384
+
385
+ @property
386
+ def start_date(self) -> datetime:
387
+ return self.end_date - timedelta(days=self.max_days_ago)
388
+
389
+ @model_validator(mode="after")
390
+ def check_max_days_ago(self) -> DownloadJobLogsInput:
391
+ if self.max_days_ago > 90:
392
+ logger.warning(f"max_days_ago for {type(self).__name__} must be less than or equal to 90, setting to 90")
393
+ self.max_days_ago = 90
394
+ return self
395
+
396
+
397
+ def download_logs(event: DownloadJobLogsInput, settings: AtlasInitSettings) -> list[Path]:
398
+ token = run_and_wait("gh auth token", cwd=event.repo_path).stdout
399
+ assert token, "expected token, but got empty string"
400
+ os.environ[GH_TOKEN_ENV_NAME] = token
401
+ end_test_date = event.end_date
402
+ start_test_date = event.start_date
403
+ log_paths = []
404
+ with new_task(
405
+ f"downloading logs for {event.branch} from {start_test_date.date()} to {end_test_date.date()}",
406
+ total=(end_test_date - start_test_date).days,
407
+ ) as task:
408
+ while start_test_date <= end_test_date:
409
+ event_out = download_gh_job_logs(
410
+ settings,
411
+ DownloadJobRunsInput(branch=event.branch, run_date=start_test_date.date()),
412
+ )
413
+ log_paths.extend(event_out.log_paths)
414
+ if errors := event_out.log_errors():
415
+ logger.warning(errors)
416
+ start_test_date += timedelta(days=1)
417
+ task.update(advance=1)
418
+ return log_paths
419
+
420
+
421
+ _TEST_STEMS = {
422
+ "test-suite",
423
+ "terraform-compatibility-matrix",
424
+ "acceptance-tests",
425
+ }
426
+
427
+
428
+ class DownloadJobRunsInput(Event):
429
+ branch: str = "master"
430
+ run_date: date
431
+ workflow_file_stems: set[str] = Field(default_factory=lambda: set(_TEST_STEMS))
432
+ worker_count: int = 10
433
+ max_wait_seconds: int = 300
434
+
435
+
436
+ class DownloadJobRunsOutput(Entity):
437
+ job_download_timeouts: int = 0
438
+ job_download_empty: int = 0
439
+ job_download_errors: int = 0
440
+ log_paths: list[Path] = Field(default_factory=list)
441
+
442
+ def log_errors(self) -> str:
443
+ if not (self.job_download_timeouts or self.job_download_empty or self.job_download_errors):
444
+ return ""
445
+ return f"job_download_timeouts: {self.job_download_timeouts}, job_download_empty: {self.job_download_empty}, job_download_errors: {self.job_download_errors}"
446
+
447
+
448
+ def created_on_day(create: date) -> str:
449
+ date_fmt = year_month_day(create)
450
+ return f"{date_fmt}T00:00:00Z..{date_fmt}T23:59:59Z"
451
+
452
+
453
+ def year_month_day(create: date) -> str:
454
+ return create.strftime("%Y-%m-%d")
455
+
456
+
457
+ def download_gh_job_logs(settings: AtlasInitSettings, event: DownloadJobRunsInput) -> DownloadJobRunsOutput:
458
+ repository = tf_repo()
459
+ branch = event.branch
460
+ futures: list[Future[Path | None]] = []
461
+ run_date = event.run_date
462
+ out = DownloadJobRunsOutput()
463
+ with ThreadPoolExecutor(max_workers=event.worker_count) as pool:
464
+ for workflow in repository.get_workflow_runs(
465
+ created=created_on_day(run_date),
466
+ branch=branch, # type: ignore
467
+ ):
468
+ workflow_stem = Path(workflow.path).stem
469
+ if workflow_stem not in event.workflow_file_stems:
470
+ continue
471
+ workflow_dir = (
472
+ settings.github_ci_run_logs / branch / year_month_day(run_date) / f"{workflow.id}_{workflow_stem}"
473
+ )
474
+ logger.info(f"workflow dir for {workflow_stem} @ {workflow.created_at.isoformat()}: {workflow_dir}")
475
+ if workflow_dir.exists():
476
+ paths = list(workflow_dir.rglob("*.log"))
477
+ logger.info(f"found {len(paths)} logs in existing workflow dir: {workflow_dir}")
478
+ out.log_paths.extend(paths)
479
+ continue
480
+ futures.extend(
481
+ pool.submit(download_job_safely, workflow_dir, job)
482
+ for job in workflow.jobs("all")
483
+ if is_test_job(job.name)
484
+ )
485
+ done, not_done = wait(futures, timeout=event.max_wait_seconds)
486
+ out.job_download_timeouts = len(not_done)
487
+ for future in done:
488
+ try:
489
+ if log_path := future.result():
490
+ out.log_paths.append(log_path)
491
+ else:
492
+ out.job_download_empty += 1
493
+ except Exception as e:
494
+ logger.error(f"failed to download job logs: {e}")
495
+ out.job_download_errors += 1
496
+ return out
497
+
498
+
499
+ class ParseJobLogsInput(Event):
500
+ settings: AtlasInitSettings
501
+ log_paths: list[Path]
502
+ resources: TFResources
503
+ branch: str
504
+
505
+
506
+ class ParseJobLogsOutput(Event):
507
+ test_runs: list[GoTestRun] = Field(default_factory=list)
508
+
509
+ def tests_with_status(self, status: GoTestStatus) -> list[GoTestRun]:
510
+ return [test for test in self.test_runs if test.status == status]
511
+
512
+
513
+ def parse_job_tf_test_logs(
514
+ event: ParseJobLogsInput,
515
+ ) -> ParseJobLogsOutput:
516
+ out = ParseJobLogsOutput()
517
+ for log_path in event.log_paths:
518
+ log_text = log_path.read_text()
519
+ env = find_env_of_mongodb_base_url(log_text)
520
+ try:
521
+ result = parse_tests(log_text.splitlines())
522
+ except ValidationError as e:
523
+ logger.warning(f"failed to parse tests from {log_path}: {e}")
524
+ continue
525
+ for test in result:
526
+ test.log_path = log_path
527
+ test.env = env or "unknown"
528
+ test.resources = event.resources.find_test_resources(test)
529
+ test.branch = event.branch
530
+ out.test_runs.extend(result)
531
+ return out
532
+
533
+
534
+ def find_env_of_mongodb_base_url(log_text: str) -> str:
535
+ for match in re.finditer(r"MONGODB_ATLAS_BASE_URL: (.*)$", log_text, re.MULTILINE):
536
+ full_url = match.group(1)
537
+ parsed = BaseURLEnvironment(url=Url(full_url))
538
+ return parsed.env
539
+ return ""
540
+
541
+
542
+ class BaseURLEnvironment(Entity):
543
+ """
544
+ >>> BaseURLEnvironment(url="https://cloud-dev.mongodb.com/").env
545
+ 'dev'
546
+ """
547
+
548
+ url: Url
549
+ env: str = ""
550
+
551
+ @model_validator(mode="after")
552
+ def set_env(self) -> BaseURLEnvironment:
553
+ host = self.url.host
554
+ assert host, f"host not found in url: {self.url}"
555
+ cloud_env = host.split(".")[0]
556
+ self.env = cloud_env.removeprefix("cloud-")
557
+ return self
558
+
559
+
560
+ class ParseTestErrorInput(Event):
561
+ test: GoTestRun
562
+ api_spec_paths: ApiSpecPaths | None = None
563
+
564
+
565
+ def parse_test_error(event: ParseTestErrorInput) -> GoTestError:
566
+ run = event.test
567
+ assert run.is_failure, f"test is not failed: {run.name}"
568
+ details = parse_error_details(run)
569
+ info = DetailsInfo(run=run, paths=event.api_spec_paths)
570
+ details.add_info_fields(info)
571
+ return GoTestError(details=details, run=run)
572
+
573
+
574
+ def ask_user_to_classify_error(cls: GoTestErrorClassification, test: GoTestRun) -> GoTestErrorClass | None:
575
+ details = cls.details
576
+ try:
577
+ print_to_live(test.output_lines_str)
578
+ print_to_live(f"error details: {details}")
579
+ return select_list(
580
+ f"choose classification for test='{test.name_with_package}' in {test.env}",
581
+ choices=list(GoTestErrorClass),
582
+ default=cls.error_class,
583
+ ) # type: ignore
584
+ except KeyboardInterrupt:
585
+ return None
File without changes
@@ -0,0 +1,97 @@
1
+ from pydantic import BaseModel, Field
2
+ from typing import Dict, List, Optional
3
+ from enum import Enum
4
+
5
+
6
+ class HttpMethod(str, Enum):
7
+ """HTTP methods enum"""
8
+
9
+ GET = "GET"
10
+ POST = "POST"
11
+ PATCH = "PATCH"
12
+ DELETE = "DELETE"
13
+ PUT = "PUT"
14
+
15
+
16
+ class WaitConfig(BaseModel):
17
+ """Configuration for waiting/polling operations"""
18
+
19
+ state_property: str = Field(..., description="Property to check for state")
20
+ pending_states: List[str] = Field(..., description="States that indicate operation is still in progress")
21
+ target_states: List[str] = Field(..., description="States that indicate operation is complete")
22
+ timeout_seconds: int = Field(..., description="Maximum time to wait in seconds")
23
+ min_timeout_seconds: int = Field(..., description="Minimum timeout in seconds")
24
+ delay_seconds: int = Field(..., description="Delay between polling attempts in seconds")
25
+
26
+
27
+ class OperationConfig(BaseModel):
28
+ """Configuration for a single API operation (read, create, update, delete)"""
29
+
30
+ path: str = Field(..., description="API endpoint path with path parameters")
31
+ method: HttpMethod = Field(..., description="HTTP method for the operation")
32
+ wait: Optional[WaitConfig] = Field(None, description="Wait configuration for async operations")
33
+
34
+
35
+ class SchemaOverrides(BaseModel):
36
+ """Schema overrides for specific fields"""
37
+
38
+ sensitive: Optional[bool] = Field(None, description="Mark field as sensitive")
39
+ # Add other override properties as needed
40
+
41
+
42
+ class SchemaConfig(BaseModel):
43
+ """Schema configuration for the resource"""
44
+
45
+ aliases: Optional[Dict[str, str]] = Field(None, description="Field name aliases mapping")
46
+ overrides: Optional[Dict[str, SchemaOverrides]] = Field(None, description="Field-specific overrides")
47
+ ignores: Optional[List[str]] = Field(None, description="Fields to ignore")
48
+ timeouts: Optional[List[str]] = Field(None, description="Operations that support timeouts")
49
+
50
+
51
+ class ResourceConfig(BaseModel):
52
+ """Configuration for a single API resource"""
53
+
54
+ read: Optional[OperationConfig] = Field(None, description="Read operation configuration")
55
+ create: Optional[OperationConfig] = Field(None, description="Create operation configuration")
56
+ update: Optional[OperationConfig] = Field(None, description="Update operation configuration")
57
+ delete: Optional[OperationConfig] = Field(None, description="Delete operation configuration")
58
+ version_header: Optional[str] = Field(None, description="API version header value")
59
+ custom_schema: Optional[SchemaConfig] = Field(None, description="Schema configuration", alias="schema")
60
+
61
+ @property
62
+ def paths(self) -> list[str]:
63
+ return [
64
+ operation.path
65
+ for operation in [self.read, self.create, self.update, self.delete]
66
+ if operation and operation.path
67
+ ]
68
+
69
+
70
+ class ApiResourcesConfig(BaseModel):
71
+ """Root configuration model containing all API resources"""
72
+
73
+ resources: Dict[str, ResourceConfig] = Field(..., description="Dictionary of resource configurations")
74
+
75
+ class Config:
76
+ extra = "allow" # Allow additional fields not explicitly defined
77
+
78
+ def get_resource(self, name: str) -> ResourceConfig:
79
+ """Get a specific resource configuration by name"""
80
+ resource = self.resources.get(name)
81
+ if not resource:
82
+ raise ValueError(f"Resource '{name}' not found in configuration")
83
+ return resource
84
+
85
+ def list_resources(self) -> List[str]:
86
+ """Get list of all resource names"""
87
+ return list(self.resources.keys())
88
+
89
+ def get_resources_with_operation(self, operation: str) -> List[str]:
90
+ """Get list of resources that support a specific operation"""
91
+ result = []
92
+ result.extend(
93
+ name
94
+ for name, config in self.resources.items()
95
+ if hasattr(config, operation) and getattr(config, operation) is not None
96
+ )
97
+ return result