qontract-reconcile 0.10.1rc537__py3-none-any.whl → 0.10.1rc539__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.
@@ -4,6 +4,7 @@ from collections.abc import (
4
4
  Mapping,
5
5
  )
6
6
  from typing import Any
7
+ from unittest.mock import MagicMock, create_autospec
7
8
 
8
9
  import pytest
9
10
  from pytest_mock import MockerFixture
@@ -12,6 +13,7 @@ import reconcile.terraform_resources as integ
12
13
  from reconcile.gql_definitions.terraform_resources.terraform_resources_namespaces import (
13
14
  NamespaceV1,
14
15
  )
16
+ from reconcile.utils.secret_reader import SecretReaderBase
15
17
 
16
18
 
17
19
  def test_cannot_use_exclude_accounts_if_not_dry_run():
@@ -71,7 +73,7 @@ def test_cannot_pass_two_aws_account_if_not_dry_run():
71
73
  def test_filter_accounts_by_name():
72
74
  accounts = [{"name": "a"}, {"name": "b"}, {"name": "c"}]
73
75
 
74
- filtered = integ.filter_accounts_by_name(accounts, filter=("a", "b"))
76
+ filtered = integ.filter_accounts_by_name(accounts, names=("a", "b"))
75
77
 
76
78
  assert filtered == [{"name": "a"}, {"name": "b"}]
77
79
 
@@ -79,7 +81,7 @@ def test_filter_accounts_by_name():
79
81
  def test_exclude_accounts_by_name():
80
82
  accounts = [{"name": "a"}, {"name": "b"}, {"name": "c"}]
81
83
 
82
- filtered = integ.exclude_accounts_by_name(accounts, filter=("a", "b"))
84
+ filtered = integ.exclude_accounts_by_name(accounts, names=("a", "b"))
83
85
 
84
86
  assert filtered == [{"name": "c"}]
85
87
 
@@ -258,27 +260,255 @@ def test_filter_tf_namespaces_namespace_deleted(gql_class_factory: Callable):
258
260
  assert filtered == [ns2]
259
261
 
260
262
 
261
- def test_empty_run(mocker: MockerFixture) -> None:
263
+ def setup_mocks(
264
+ mocker: MockerFixture,
265
+ secret_reader: SecretReaderBase,
266
+ aws_accounts: list[dict[str, Any]],
267
+ tf_namespaces: list[NamespaceV1],
268
+ feature_toggle_state: bool = True,
269
+ ) -> dict[str, Any]:
262
270
  mocked_queries = mocker.patch("reconcile.terraform_resources.queries")
263
- mocked_queries.get_aws_accounts.return_value = [{"name": "a"}]
271
+ mocked_queries.get_aws_accounts.return_value = aws_accounts
264
272
  mocked_queries.get_app_interface_settings.return_value = []
265
273
 
266
- mocker.patch("reconcile.terraform_resources.get_namespaces").return_value = []
274
+ mocker.patch(
275
+ "reconcile.terraform_resources.get_namespaces"
276
+ ).return_value = tf_namespaces
267
277
 
268
- mocked_ts = mocker.patch("reconcile.terraform_resources.Terrascript", autospec=True)
269
- mocked_ts.return_value.resource_spec_inventory = {}
278
+ mocked_ts = mocker.patch(
279
+ "reconcile.terraform_resources.Terrascript", autospec=True
280
+ ).return_value
281
+ mocked_ts.resource_spec_inventory = {}
270
282
 
271
- mocked_tf = mocker.patch("reconcile.terraform_resources.Terraform", autospec=True)
272
- mocked_tf.return_value.plan.return_value = (False, None)
273
- mocked_tf.return_value.should_apply = False
283
+ mocked_tf = mocker.patch(
284
+ "reconcile.terraform_resources.Terraform", autospec=True
285
+ ).return_value
286
+ mocked_tf.plan.return_value = (False, None)
287
+ mocked_tf.should_apply.return_value = False
274
288
 
275
289
  mocker.patch("reconcile.terraform_resources.AWSApi", autospec=True)
276
- mocker.patch("reconcile.terraform_resources.sys")
277
290
 
278
291
  mocked_logging = mocker.patch("reconcile.terraform_resources.logging")
279
292
 
293
+ mocker.patch("reconcile.terraform_resources.get_app_interface_vault_settings")
294
+
295
+ mocker.patch(
296
+ "reconcile.terraform_resources.create_secret_reader",
297
+ return_value=secret_reader,
298
+ )
299
+
300
+ mock_extended_early_exit_run = mocker.patch(
301
+ "reconcile.terraform_resources.extended_early_exit_run"
302
+ )
303
+
304
+ get_feature_toggle_state = mocker.patch(
305
+ "reconcile.terraform_resources.get_feature_toggle_state",
306
+ return_value=feature_toggle_state,
307
+ )
308
+
309
+ return {
310
+ "queries": mocked_queries,
311
+ "ts": mocked_ts,
312
+ "tf": mocked_tf,
313
+ "logging": mocked_logging,
314
+ "extended_early_exit_run": mock_extended_early_exit_run,
315
+ "get_feature_toggle_state": get_feature_toggle_state,
316
+ }
317
+
318
+
319
+ def test_empty_run(
320
+ mocker: MockerFixture,
321
+ secret_reader: SecretReaderBase,
322
+ ) -> None:
323
+ mocks = setup_mocks(
324
+ mocker,
325
+ secret_reader,
326
+ aws_accounts=[{"name": "a"}],
327
+ tf_namespaces=[],
328
+ )
329
+
280
330
  integ.run(True, account_name="a")
281
331
 
282
- mocked_logging.warning.assert_called_once_with(
332
+ mocks["logging"].warning.assert_called_once_with(
283
333
  "No terraform namespaces found, consider disabling this integration, account names: a"
284
334
  )
335
+
336
+
337
+ def test_run_with_extended_early_exit_run_enabled(
338
+ mocker: MockerFixture,
339
+ secret_reader: SecretReaderBase,
340
+ ) -> None:
341
+ mocks = setup_mocks(
342
+ mocker,
343
+ secret_reader,
344
+ aws_accounts=[{"name": "a"}],
345
+ tf_namespaces=[],
346
+ )
347
+ defer = MagicMock()
348
+ expected_runner_params = integ.RunnerParams(
349
+ accounts=[{"name": "a"}],
350
+ account_names={"a"},
351
+ tf_namespaces=[],
352
+ tf=mocks["tf"],
353
+ ts=mocks["ts"],
354
+ secret_reader=secret_reader,
355
+ dry_run=True,
356
+ enable_deletion=False,
357
+ thread_pool_size=10,
358
+ internal=None,
359
+ use_jump_host=True,
360
+ light=False,
361
+ vault_output_path="",
362
+ defer=defer,
363
+ )
364
+
365
+ integ.run.__wrapped__(
366
+ True,
367
+ account_name="a",
368
+ enable_extended_early_exit=True,
369
+ extended_early_exit_cache_ttl_seconds=60,
370
+ log_cached_log_output=True,
371
+ defer=defer,
372
+ )
373
+
374
+ mocks["extended_early_exit_run"].assert_called_once_with(
375
+ integration=integ.QONTRACT_INTEGRATION,
376
+ integration_version=integ.QONTRACT_INTEGRATION_VERSION,
377
+ dry_run=True,
378
+ cache_source=mocks["ts"].terraform_configurations.return_value,
379
+ ttl_seconds=60,
380
+ logger=mocks["logging"].getLogger.return_value,
381
+ runner=integ.runner,
382
+ runner_params=expected_runner_params,
383
+ secret_reader=secret_reader,
384
+ log_cached_log_output=True,
385
+ )
386
+
387
+
388
+ def test_run_with_extended_early_exit_run_disabled(
389
+ mocker: MockerFixture,
390
+ secret_reader: SecretReaderBase,
391
+ ) -> None:
392
+ mocks = setup_mocks(
393
+ mocker,
394
+ secret_reader,
395
+ aws_accounts=[{"name": "a"}],
396
+ tf_namespaces=[],
397
+ )
398
+
399
+ integ.run(
400
+ True,
401
+ account_name="a",
402
+ enable_extended_early_exit=False,
403
+ )
404
+
405
+ mocks["extended_early_exit_run"].assert_not_called()
406
+ mocks["tf"].plan.assert_called_once_with(False)
407
+
408
+
409
+ def test_run_with_extended_early_exit_run_feature_disabled(
410
+ mocker: MockerFixture,
411
+ secret_reader: SecretReaderBase,
412
+ ) -> None:
413
+ mocks = setup_mocks(
414
+ mocker,
415
+ secret_reader,
416
+ aws_accounts=[{"name": "a"}],
417
+ tf_namespaces=[],
418
+ feature_toggle_state=False,
419
+ )
420
+
421
+ integ.run(
422
+ True,
423
+ account_name="a",
424
+ enable_extended_early_exit=True,
425
+ )
426
+
427
+ mocks["extended_early_exit_run"].assert_not_called()
428
+ mocks["tf"].plan.assert_called_once_with(False)
429
+ mocks["get_feature_toggle_state"].assert_called_once_with(
430
+ "terraform-resources-extended-early-exit",
431
+ default=False,
432
+ )
433
+
434
+
435
+ def test_terraform_resources_runner_dry_run(
436
+ secret_reader: SecretReaderBase,
437
+ ) -> None:
438
+ tf = create_autospec(integ.Terraform)
439
+ tf.plan.return_value = (False, None)
440
+
441
+ ts = create_autospec(integ.Terrascript)
442
+ terraform_configurations = {"a": "b"}
443
+ ts.terraform_configurations.return_value = terraform_configurations
444
+
445
+ defer = MagicMock()
446
+
447
+ runner_params = dict(
448
+ accounts=[{"name": "a"}],
449
+ account_names={"a"},
450
+ tf_namespaces=[],
451
+ tf=tf,
452
+ ts=ts,
453
+ secret_reader=secret_reader,
454
+ dry_run=True,
455
+ enable_deletion=False,
456
+ thread_pool_size=10,
457
+ internal=None,
458
+ use_jump_host=True,
459
+ light=False,
460
+ vault_output_path="",
461
+ defer=defer,
462
+ )
463
+
464
+ result = integ.runner(**runner_params)
465
+
466
+ assert result == integ.ExtendedEarlyExitRunnerResult(
467
+ payload=terraform_configurations,
468
+ applied_count=0,
469
+ )
470
+
471
+
472
+ def test_terraform_resources_runner_no_dry_run(
473
+ mocker: MockerFixture,
474
+ secret_reader: SecretReaderBase,
475
+ ) -> None:
476
+ tf = create_autospec(integ.Terraform)
477
+ tf.plan.return_value = (False, None)
478
+ tf.apply_count = 1
479
+ tf.should_apply.return_value = True
480
+ tf.apply.return_value = False
481
+
482
+ ts = create_autospec(integ.Terrascript)
483
+ terraform_configurations = {"a": "b"}
484
+ ts.terraform_configurations.return_value = terraform_configurations
485
+ ts.resource_spec_inventory = {}
486
+
487
+ defer = MagicMock()
488
+
489
+ mocked_ob = mocker.patch("reconcile.terraform_resources.ob")
490
+ mocked_ob.realize_data.return_value = [{"action": "applied"}]
491
+
492
+ runner_params = dict(
493
+ accounts=[{"name": "a"}],
494
+ account_names={"a"},
495
+ tf_namespaces=[],
496
+ tf=tf,
497
+ ts=ts,
498
+ secret_reader=secret_reader,
499
+ dry_run=False,
500
+ enable_deletion=False,
501
+ thread_pool_size=10,
502
+ internal=None,
503
+ use_jump_host=True,
504
+ light=False,
505
+ vault_output_path="",
506
+ defer=defer,
507
+ )
508
+
509
+ result = integ.runner(**runner_params)
510
+
511
+ assert result == integ.ExtendedEarlyExitRunnerResult(
512
+ payload=terraform_configurations,
513
+ applied_count=2,
514
+ )
@@ -1,6 +1,6 @@
1
1
  from datetime import UTC, datetime, timedelta
2
2
  from enum import Enum
3
- from typing import Any, Optional, Self
3
+ from typing import Any, Self
4
4
 
5
5
  from deepdiff import DeepHash
6
6
  from pydantic import BaseModel
@@ -16,19 +16,19 @@ class CacheKey(BaseModel):
16
16
  integration: str
17
17
  integration_version: str
18
18
  dry_run: bool
19
- cache_desired_state: object
19
+ cache_source: object
20
20
 
21
21
  def __str__(self) -> str:
22
22
  return "/".join([
23
23
  self.integration,
24
24
  self.integration_version,
25
25
  "dry-run" if self.dry_run else "no-dry-run",
26
- DeepHash(self.cache_desired_state)[self.cache_desired_state],
26
+ DeepHash(self.cache_source)[self.cache_source],
27
27
  ])
28
28
 
29
29
 
30
30
  class CacheValue(BaseModel):
31
- desired_state: object
31
+ payload: object
32
32
  log_output: str
33
33
  applied_count: int
34
34
 
@@ -46,7 +46,7 @@ class EarlyExitCache:
46
46
  @classmethod
47
47
  def build(
48
48
  cls,
49
- secret_reader: Optional[SecretReaderBase] = None,
49
+ secret_reader: SecretReaderBase | None = None,
50
50
  ) -> Self:
51
51
  state = init_state(STATE_INTEGRATION, secret_reader)
52
52
  return cls(state)
@@ -0,0 +1,177 @@
1
+ import logging
2
+ from collections.abc import Callable, Generator, Mapping
3
+ from contextlib import contextmanager
4
+ from io import StringIO
5
+ from logging import Logger
6
+
7
+ from pydantic import BaseModel
8
+
9
+ from reconcile.utils.early_exit_cache import (
10
+ CacheKey,
11
+ CacheStatus,
12
+ CacheValue,
13
+ EarlyExitCache,
14
+ )
15
+ from reconcile.utils.metrics import (
16
+ CounterMetric,
17
+ GaugeMetric,
18
+ inc_counter,
19
+ normalize_integration_name,
20
+ set_gauge,
21
+ )
22
+ from reconcile.utils.secret_reader import SecretReaderBase
23
+
24
+
25
+ class ExtendedEarlyExitRunnerResult(BaseModel):
26
+ payload: object
27
+ applied_count: int
28
+
29
+
30
+ class ExtendedEarlyExitBaseMetric(BaseModel):
31
+ integration: str
32
+ integration_version: str
33
+ dry_run: bool
34
+ cache_status: str
35
+
36
+
37
+ class ExtendedEarlyExitCounter(ExtendedEarlyExitBaseMetric, CounterMetric):
38
+ @classmethod
39
+ def name(cls) -> str:
40
+ return "qontract_reconcile_extended_early_exit"
41
+
42
+
43
+ class ExtendedEarlyExitAppliedCountGauge(ExtendedEarlyExitBaseMetric, GaugeMetric):
44
+ @classmethod
45
+ def name(cls) -> str:
46
+ return "qontract_reconcile_extended_early_exit_applied_count"
47
+
48
+
49
+ def _publish_metrics(
50
+ cache_key: CacheKey,
51
+ cache_status: CacheStatus,
52
+ applied_count: int,
53
+ ) -> None:
54
+ inc_counter(
55
+ ExtendedEarlyExitCounter(
56
+ integration=cache_key.integration,
57
+ integration_version=cache_key.integration_version,
58
+ dry_run=cache_key.dry_run,
59
+ cache_status=cache_status.value,
60
+ ),
61
+ )
62
+ set_gauge(
63
+ ExtendedEarlyExitAppliedCountGauge(
64
+ integration=cache_key.integration,
65
+ integration_version=cache_key.integration_version,
66
+ dry_run=cache_key.dry_run,
67
+ cache_status=cache_status.value,
68
+ ),
69
+ applied_count,
70
+ )
71
+
72
+
73
+ def _ttl_seconds(
74
+ applied_count: int,
75
+ ttl_seconds: int,
76
+ ) -> int:
77
+ """
78
+ Pick the ttl based on the applied count.
79
+ If the applied count is greater than 0, then we want to set ttl to 0 so that the next run will not hit the cache,
80
+ this will allow us to easy debug reconcile loops, as we will be able to see the logs of the next run,
81
+ and check cached value for more details.
82
+
83
+ :param applied_count: The number of resources that were applied
84
+ :param ttl_seconds: A ttl in seconds
85
+ :return: The ttl in seconds
86
+ """
87
+ return 0 if applied_count > 0 else ttl_seconds
88
+
89
+
90
+ @contextmanager
91
+ def log_stream_handler(
92
+ logger: Logger,
93
+ ) -> Generator[StringIO, None, None]:
94
+ """
95
+ Add a stream handler to the logger, and return the stream generator, automatically remove the handler when done.
96
+
97
+ :param logger: A logger
98
+ :return: A stream generator
99
+ """
100
+ log_stream = StringIO()
101
+ log_handler = logging.StreamHandler(log_stream)
102
+ logger.addHandler(log_handler)
103
+ try:
104
+ yield log_stream
105
+ finally:
106
+ logger.removeHandler(log_handler)
107
+
108
+
109
+ def extended_early_exit_run(
110
+ integration: str,
111
+ integration_version: str,
112
+ dry_run: bool,
113
+ cache_source: object,
114
+ ttl_seconds: int,
115
+ logger: Logger,
116
+ runner: Callable[..., ExtendedEarlyExitRunnerResult],
117
+ runner_params: Mapping | None = None,
118
+ secret_reader: SecretReaderBase | None = None,
119
+ log_cached_log_output: bool = False,
120
+ ) -> None:
121
+ """
122
+ Run the runner based on the cache status. Early exit when cache hit.
123
+ Runner log output will be extracted and stored in cache value,
124
+ and will be logged when hit if log_cached_log_output is True,
125
+ this is mainly used to show all log output from different integrations in one place (CI).
126
+ When runner returns no applies (applied_count is 0), the ttl will be set to ttl_seconds,
127
+ otherwise it will be set to 0.
128
+
129
+ :param integration: The integration name
130
+ :param integration_version: The integration version
131
+ :param dry_run: True if the run is in dry run mode, False otherwise
132
+ :param cache_source: The cache source, usually the static desired state
133
+ :param ttl_seconds: A ttl in seconds
134
+ :param logger: A Logger
135
+ :param runner: A runner can return ExtendedEarlyExitRunnerResult when called
136
+ :param runner_params: Runner params, will be spread into kwargs when calling runner
137
+ :param secret_reader: A secret reader
138
+ :param log_cached_log_output: Whether to log the cached log output when there is a cache hit
139
+ :return: None
140
+ """
141
+ with EarlyExitCache.build(secret_reader) as cache:
142
+ key = CacheKey(
143
+ integration=normalize_integration_name(integration),
144
+ integration_version=integration_version,
145
+ dry_run=dry_run,
146
+ cache_source=cache_source,
147
+ )
148
+ cache_status = cache.head(key)
149
+ logger.debug("Early exit cache status for key=%s: %s", key, cache_status)
150
+
151
+ if cache_status == CacheStatus.HIT:
152
+ if log_cached_log_output:
153
+ logger.info(cache.get(key).log_output)
154
+ _publish_metrics(
155
+ cache_key=key,
156
+ cache_status=cache_status,
157
+ applied_count=0,
158
+ )
159
+ return
160
+
161
+ with log_stream_handler(logger) as log_stream:
162
+ result = runner(**(runner_params or {}))
163
+ log_output = log_stream.getvalue()
164
+
165
+ value = CacheValue(
166
+ payload=result.payload,
167
+ log_output=log_output,
168
+ applied_count=result.applied_count,
169
+ )
170
+ ttl = _ttl_seconds(result.applied_count, ttl_seconds)
171
+ logger.debug("Set early exit cache for key=%s with ttl=%d", key, ttl)
172
+ cache.set(key, value, ttl)
173
+ _publish_metrics(
174
+ cache_key=key,
175
+ cache_status=cache_status,
176
+ applied_count=result.applied_count,
177
+ )
@@ -79,11 +79,12 @@ def get_inventory_count_combinations(
79
79
 
80
80
  def publish_metrics(inventory: ExternalResourceSpecInventory, integration: str) -> None:
81
81
  count_combinations = get_inventory_count_combinations(inventory)
82
+ integration_name = metrics.normalize_integration_name(integration)
82
83
  for combination, count in count_combinations.items():
83
84
  provision_provider, provisioner_name, provider = combination
84
85
  metrics.set_gauge(
85
86
  ExternalResourceInventoryGauge(
86
- integration=integration.replace("_", "-"),
87
+ integration=integration_name,
87
88
  provision_provider=provision_provider,
88
89
  provisioner_name=provisioner_name,
89
90
  provider=provider,
@@ -154,6 +154,10 @@ class Project(BaseModel):
154
154
  platform: Optional[str]
155
155
  teams: list[Team] = []
156
156
  alerts: list[ProjectAlert] = []
157
+ event_throttle_rate: int = Field(0, alias="eventThrottleRate")
158
+
159
+ class Config:
160
+ allow_population_by_field_name = True
157
161
 
158
162
  @root_validator
159
163
  def slugify( # pylint: disable=no-self-argument
@@ -174,7 +178,11 @@ class Project(BaseModel):
174
178
  return self.slug == other.slug
175
179
 
176
180
  def diff(self, other: Project) -> bool:
177
- return self.name != other.name or self.platform != other.platform
181
+ return (
182
+ self.name != other.name
183
+ or self.platform != other.platform
184
+ or self.event_throttle_rate != other.event_throttle_rate
185
+ )
178
186
 
179
187
  def __hash__(self) -> int:
180
188
  return hash(self.slug)
@@ -563,3 +563,10 @@ class ErrorRateMetricSet:
563
563
  if exc_value:
564
564
  self.fail(exc_value)
565
565
  inc_counter(self._error_counter, by=(1 if self._errors else 0))
566
+
567
+
568
+ def normalize_integration_name(integration: str) -> str:
569
+ """
570
+ Normalize the integration name to be used in prometheus.
571
+ """
572
+ return integration.replace("_", "-")
@@ -90,7 +90,7 @@ class TerraformClient: # pylint: disable=too-many-public-methods
90
90
  self.thread_pool_size = thread_pool_size
91
91
  self._aws_api = aws_api
92
92
  self._log_lock = Lock()
93
- self.should_apply = False
93
+ self.apply_count = 0
94
94
 
95
95
  self.specs: list[TerraformSpec] = []
96
96
  self.init_specs()
@@ -111,6 +111,12 @@ class TerraformClient: # pylint: disable=too-many-public-methods
111
111
  for account, output in self.outputs.items()
112
112
  }
113
113
 
114
+ def increment_apply_count(self):
115
+ self.apply_count += 1
116
+
117
+ def should_apply(self) -> bool:
118
+ return self.apply_count > 0
119
+
114
120
  def get_new_users(self):
115
121
  new_users = []
116
122
  self.init_outputs() # get updated output
@@ -282,7 +288,7 @@ class TerraformClient: # pylint: disable=too-many-public-methods
282
288
  after = output_change.get("after")
283
289
  if before != after:
284
290
  logging.info(["update", name, "output", output_name])
285
- self.should_apply = True
291
+ self.increment_apply_count()
286
292
 
287
293
  # A way to detect deleted outputs is by comparing
288
294
  # the prior state with the output changes.
@@ -295,7 +301,7 @@ class TerraformClient: # pylint: disable=too-many-public-methods
295
301
  deleted_outputs = [po for po in prior_outputs if po not in output_changes]
296
302
  for output_name in deleted_outputs:
297
303
  logging.info(["delete", name, "output", output_name])
298
- self.should_apply = True
304
+ self.increment_apply_count()
299
305
 
300
306
  resource_changes = output.get("resource_changes")
301
307
  if resource_changes is None:
@@ -339,7 +345,7 @@ class TerraformClient: # pylint: disable=too-many-public-methods
339
345
  resource_name,
340
346
  self._resource_diff_changed_fields(action, resource_change),
341
347
  ])
342
- self.should_apply = True
348
+ self.increment_apply_count()
343
349
  if action == "create":
344
350
  if resource_type == "aws_iam_user_login_profile":
345
351
  created_users.append(AccountUser(name, resource_name))
@@ -3890,22 +3890,30 @@ class TerrascriptClient: # pylint: disable=too-many-public-methods
3890
3890
  if os.path.isfile(print_to_file):
3891
3891
  os.remove(print_to_file)
3892
3892
 
3893
- for name, ts in self.tss.items():
3893
+ for name, ts in self.terraform_configurations().items():
3894
3894
  if print_to_file:
3895
3895
  with open(print_to_file, "a", encoding="locale") as f:
3896
3896
  f.write(f"##### {name} #####\n")
3897
- f.write(str(ts))
3897
+ f.write(ts)
3898
3898
  f.write("\n")
3899
3899
  if existing_dirs is None:
3900
3900
  wd = tempfile.mkdtemp(prefix=TMP_DIR_PREFIX)
3901
3901
  else:
3902
3902
  wd = working_dirs[name]
3903
3903
  with open(wd + "/config.tf.json", "w", encoding="locale") as f:
3904
- f.write(str(ts))
3904
+ f.write(ts)
3905
3905
  working_dirs[name] = wd
3906
3906
 
3907
3907
  return working_dirs
3908
3908
 
3909
+ def terraform_configurations(self) -> dict[str, str]:
3910
+ """
3911
+ Return the Terraform configurations (in JSON format) for each AWS account.
3912
+
3913
+ :return: key is AWS account name and value is terraform configuration
3914
+ """
3915
+ return {name: str(ts) for name, ts in self.tss.items()}
3916
+
3909
3917
  def init_values(self, spec: ExternalResourceSpec, init_tags: bool = True) -> dict:
3910
3918
  """
3911
3919
  Initialize the values of the terraform resource and merge the defaults and