qontract-reconcile 0.10.1rc537__py3-none-any.whl → 0.10.1rc538__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qontract-reconcile
3
- Version: 0.10.1rc537
3
+ Version: 0.10.1rc538
4
4
  Summary: Collection of tools to reconcile services with their desired state as defined in the app-interface DB.
5
5
  Home-page: https://github.com/app-sre/qontract-reconcile
6
6
  Author: Red Hat App-SRE Team
@@ -9,7 +9,7 @@ reconcile/aws_iam_password_reset.py,sha256=NwErtrqgBiXr7eGCAHdtGGOx0S7-4JnSc29Ie
9
9
  reconcile/aws_support_cases_sos.py,sha256=Jk6_XjDeJSYxgRGqcEAOcynt9qJF2r5HPIPcSKmoBv8,2974
10
10
  reconcile/blackbox_exporter_endpoint_monitoring.py,sha256=W_VJagnsJR1v5oqjlI3RJJE0_nhtJ0m81RS8zWA5u5c,3538
11
11
  reconcile/checkpoint.py,sha256=R2WFXUXLTB4sWMi4GeA4eegsuf_1-Q4vH8M0Toh3Ij4,5036
12
- reconcile/cli.py,sha256=ek511mirANFglUXz_lrnIjmHZ48ZDJOx-53OFf-fSO4,84491
12
+ reconcile/cli.py,sha256=LFsNTM36MINQ_iDqIpXlJPrUcLUL7BUEE4GpPG2LmaQ,85546
13
13
  reconcile/closedbox_endpoint_monitoring_base.py,sha256=SMhkcQqprWvThrIJa3U_3uh5w1h-alleW1QnCJFY4Qw,4909
14
14
  reconcile/cluster_deployment_mapper.py,sha256=2Ah-nu-Mdig0pjuiZl_XLrmVAjYzFjORR3dMlCgkmw0,2352
15
15
  reconcile/dashdotdb_base.py,sha256=a5aPLVxyqPSbjdB0Ty-uliOtxwvEbbEljHJKxdK3-Zk,4813
@@ -111,7 +111,7 @@ reconcile/terraform_cloudflare_dns.py,sha256=auU4bzeLwd4S8D8oqpqJbrCUoEdELXrgi7v
111
111
  reconcile/terraform_cloudflare_resources.py,sha256=EbQQaoDnZ7brvRCpbFtwlD7KLk2hDVNcjhrJGaAywEk,15023
112
112
  reconcile/terraform_cloudflare_users.py,sha256=1EbTHwJgiPkJpMP-Ag340QNgGK3mXn3dcC3DpLakudM,13987
113
113
  reconcile/terraform_repo.py,sha256=c0GZFuY3rCm6VHjHqYbsgOHrEkRWKF_1LrMThsn2XDw,16127
114
- reconcile/terraform_resources.py,sha256=x5Do4xBBhjJdIVRi0Gy4h-ryCCZ6kU7bT_iB0_mGing,17105
114
+ reconcile/terraform_resources.py,sha256=3-q0WyCzBbfgvfDjCEKGp09bNGgxtbzUs9jEeoLr6u4,19176
115
115
  reconcile/terraform_tgw_attachments.py,sha256=_g7QSHM03YZzTU7O189S4HYtUn7WmwOBq67G4AieU24,15298
116
116
  reconcile/terraform_users.py,sha256=kXRUxCUchKCP2dbXXOzctynqMii4oyCP6bYZHQTrlTg,10202
117
117
  reconcile/terraform_vpc_peerings.py,sha256=rnDH1u93OyzrBM8Hib0HwSnlxZtx4ScRQaZAcn3mx-k,25402
@@ -440,7 +440,7 @@ reconcile/test/test_terraform_cloudflare_dns.py,sha256=aQTXX8Vr4h9aWvJZTnpZEhMGY
440
440
  reconcile/test/test_terraform_cloudflare_resources.py,sha256=NK_uktyWihkQ3gMN4bCaKerpi43CXAVYGIKTfcz05rY,13550
441
441
  reconcile/test/test_terraform_cloudflare_users.py,sha256=RAFtMMdqZha3jNnNNsqbNQQUDSqUzdoM63rCw7fs4Fo,27456
442
442
  reconcile/test/test_terraform_repo.py,sha256=soKFJfF8tWIimDs39RQl3Hnh-Od-bR4PfnEA2s1UprM,11552
443
- reconcile/test/test_terraform_resources.py,sha256=1ny_QSFuRjV9jxZY8EeT4NVJ5dMv7cLrEEIx_cBpjgk,9075
443
+ reconcile/test/test_terraform_resources.py,sha256=O8kCYxGKqILbbCP86eJ3ESjatdPa1m3wQYXxrVfc_eo,15082
444
444
  reconcile/test/test_terraform_tgw_attachments.py,sha256=cAq6exc-K-jtLla1CZUZQzVnBkyDnIlL7jybnddhLKc,36861
445
445
  reconcile/test/test_terraform_users.py,sha256=Xn4y6EcxnNQb6XcPoOhz_Ikxmh9Nrsu88OM1scN9hzY,5434
446
446
  reconcile/test/test_terraform_vpc_peerings.py,sha256=ubcsKh0TrUIwuI1-W3ETIgzsFvzAyeoFmEJFC-IK6JY,20538
@@ -524,13 +524,14 @@ reconcile/utils/defer.py,sha256=SniUsbgOEs9Pa8JkecLu0F94O63yQPByKXaElDYe0FI,377
524
524
  reconcile/utils/differ.py,sha256=kJmUp9ZffFPSUEviaAw3s9c92ErwRJeHaRexGPai7wA,7643
525
525
  reconcile/utils/disabled_integrations.py,sha256=avdDsFyl_LdTsrPVzlcIhWzT_V4C4MXw1ZC__aOtluE,1126
526
526
  reconcile/utils/dnsutils.py,sha256=VX4gDQXpiMYVuT0pvNbzzSgfqmsWOM2qtjNQHUyIYF8,370
527
- reconcile/utils/early_exit_cache.py,sha256=EOZK-Z3w9SXooa42p-mwCa6LbIxZFJ7qWlyUcXsus7w,2344
527
+ reconcile/utils/early_exit_cache.py,sha256=2uaiYIILA64D8mqqPj-GSvVBa80bN5XQRKz9x33iIpc,2304
528
528
  reconcile/utils/elasticsearch_exceptions.py,sha256=UY5Z3y2hw7T73sPJ6dHmUybegiIophrKFdTfdsOa6UY,379
529
529
  reconcile/utils/environ.py,sha256=VnW3zp6Un_UJn5BU4FU8RfhuqtZp0s-VeuuHnqC_WcQ,515
530
530
  reconcile/utils/exceptions.py,sha256=DwfnWUpVOotpP79RWZ2pycmG6nKCL00RBIeZLYkQPW4,635
531
531
  reconcile/utils/expiration.py,sha256=BXwKE50sNIV-Lszke97fxitNkLxYszoOLW1LBgp_yqg,1246
532
+ reconcile/utils/extended_early_exit.py,sha256=gLWRtgzRB584iX4pVfGjfZxbIDd_AQPYu8MkQkReA3U,5688
532
533
  reconcile/utils/external_resource_spec.py,sha256=OGPKH3IKXgJszRTgE5U_QKgU-s4BHQnx97Lj-Krz46k,6655
533
- reconcile/utils/external_resources.py,sha256=eF9Wup8zbLWx56WoA0FlqguT6BRXRYgoyN3cbmpT_Dk,7443
534
+ reconcile/utils/external_resources.py,sha256=a2CkJ3KLociYBnc_9F2VWfZGWMhzDl6fDNhwo2U-MWU,7501
534
535
  reconcile/utils/filtering.py,sha256=zZnHH0u0SaTDyzuFXZ_mREURGLvjEqQIQy4z-7QBVlc,419
535
536
  reconcile/utils/git.py,sha256=Qad7mfPuS9s7eKODeWSewehwSGgJPCbQuLda1qg_6GA,1522
536
537
  reconcile/utils/git_secrets.py,sha256=0wGNL5mvDtVPRuu3vEQgld1Am64gIDJHtmu1_ZKxMAI,1973
@@ -553,7 +554,7 @@ reconcile/utils/keycloak.py,sha256=UqOsAcHKmmIunroWB5YzC1fUZ3S3aq6L7trn6vLRmXY,3
553
554
  reconcile/utils/ldap_client.py,sha256=ho4veSrHqQWs0YhLFyKeD-duCwY8Nc5gUIA5qLENuMY,2502
554
555
  reconcile/utils/lean_terraform_client.py,sha256=zReyNPJbr2uOdrdh8Qfe-OZQBoRwxb5Za_ddeoUCYVk,4064
555
556
  reconcile/utils/make.py,sha256=QaEwucrzbl8-VHS66Wfdjfo0ubmAcvt_hZGpiGsKU50,231
556
- reconcile/utils/metrics.py,sha256=r93j_9WmsR4jC9uqgGrkzk9gycolhZhbsomHcpmJv8g,18297
557
+ reconcile/utils/metrics.py,sha256=7nXdctmZ0UtGMHPpS3V55sfH4xpMPqdYaJ3JKAUc_sM,18474
557
558
  reconcile/utils/models.py,sha256=R3wF68yiaT0H2sQVMmHhHSTGI3JKKYEVjoizhHkySn8,4533
558
559
  reconcile/utils/oc.py,sha256=iAoM7zSm72zBOmCNW19ttAvm76tUgS26vlpb8SvVYwU,64058
559
560
  reconcile/utils/oc_connection_parameters.py,sha256=85slrnDigYwYmzhyceVkMElWzFArp4ge1d-fHXVqh0w,9729
@@ -580,8 +581,8 @@ reconcile/utils/sqs_gateway.py,sha256=gFl9DM4DmGnptuxTOe4lS3YTyE80eSAvK42ljS8h4d
580
581
  reconcile/utils/state.py,sha256=SAa6QLHu9lr0yqLCBy2AypNx1IPCJWlrRBrvlzAKsOU,14505
581
582
  reconcile/utils/structs.py,sha256=LcbLEg8WxfRqM6nW7NhcWN0YeqF7SQzxOgntmLs1SgY,352
582
583
  reconcile/utils/template.py,sha256=wTvRU4AnAV_o042tD4Mwls2dwWMuk7MKnde3MaCjaYg,331
583
- reconcile/utils/terraform_client.py,sha256=V7AMQOEU4tvUOT-LQN2cXLqcphD5L93PMGMfurQQyPY,31753
584
- reconcile/utils/terrascript_aws_client.py,sha256=wA6AcAqbS2tIuqh8soMfpdpuf0IbAJWjONMmffJ-0vA,265467
584
+ reconcile/utils/terraform_client.py,sha256=_jBriLBwU005bDxWlq7CRByOkVCfiH47oBzB0ArNAY8,31901
585
+ reconcile/utils/terrascript_aws_client.py,sha256=Ht2akaR4bRERLoyN_Zh2JBbN1-p-ofXICqW-oXzGcFk,265789
585
586
  reconcile/utils/three_way_diff_strategy.py,sha256=nyqeQsLCoPI6e16k2CF3b9KNgQLU-rPf5RtfdUfVMwE,4468
586
587
  reconcile/utils/throughput.py,sha256=iP4UWAe2LVhDo69mPPmgo9nQ7RxHD6_GS8MZe-aSiuM,344
587
588
  reconcile/utils/unleash.py,sha256=1D56CsZfE3ShDtN3IErE1T2eeIwNmxhK-yYbCotJ99E,3601
@@ -657,7 +658,7 @@ tools/app_interface_metrics_exporter.py,sha256=zkwkxdAUAxjdc-pzx2_oJXG25fo0Fnyd5
657
658
  tools/app_interface_reporter.py,sha256=upA-J-n-HXHKVDINRuMR7vTt-iJvQORKUVi9D3leQto,17738
658
659
  tools/glitchtip_access_reporter.py,sha256=oPBnk_YoDuljU3v0FaChzOwwnk4vap1xEE67QEjzdqs,2948
659
660
  tools/glitchtip_access_revalidation.py,sha256=8kbBJk04mkq28kWoRDDkfCGIF3GRg3pJrFAh1sW0dbk,2821
660
- tools/qontract_cli.py,sha256=bw7JVS9zaob2hvb-0MkGovTEK5HnxAy5ms-MEAU9WQU,103543
661
+ tools/qontract_cli.py,sha256=mN5oXM_lmFmCGwoGU77qqO8nzlfKnNGlP6y-ILFyHIQ,103423
661
662
  tools/sd_app_sre_alert_report.py,sha256=e9vAdyenUz2f5c8-z-5WY0wv-SJ9aePKDH2r4IwB6pc,5063
662
663
  tools/cli_commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
663
664
  tools/cli_commands/gpg_encrypt.py,sha256=w8hl4jIEWk5wKbEFN6fVEOwUJGmdlvOqYodW3XSN7mU,4978
@@ -665,11 +666,11 @@ tools/sre_checkpoints/__init__.py,sha256=CDaDaywJnmRCLyl_NCcvxi-Zc0hTi_3OdwKiFOy
665
666
  tools/sre_checkpoints/util.py,sha256=zEDbGr18ZeHNQwW8pUsr2JRjuXIPz--WAGJxZo9sv_Y,894
666
667
  tools/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
667
668
  tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvftCWEEf-g1mfXOtgCog-g,1271
668
- tools/test/test_qontract_cli.py,sha256=d18KrdhtUGqoC7_kWZU128U0-VJEj-0rjFkLVufcI6I,2755
669
+ tools/test/test_qontract_cli.py,sha256=se-YG_YVCWRFrnCPvBVHDBT_59CkbIoEni-4SJa8_MU,2755
669
670
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
670
671
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
671
- qontract_reconcile-0.10.1rc537.dist-info/METADATA,sha256=dGXxQQQzpUMW_Kyo_ktrTXrUVvKSJ9GMS7SG5jKdMoo,2349
672
- qontract_reconcile-0.10.1rc537.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
673
- qontract_reconcile-0.10.1rc537.dist-info/entry_points.txt,sha256=rTjAv28I_CHLM8ID3OPqMI_suoQ9s7tFbim4aYjn9kk,376
674
- qontract_reconcile-0.10.1rc537.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
675
- qontract_reconcile-0.10.1rc537.dist-info/RECORD,,
672
+ qontract_reconcile-0.10.1rc538.dist-info/METADATA,sha256=TkmlNiYqcISDkpP1_R0KR4m709lpVpeeJUAkfjVEFVc,2349
673
+ qontract_reconcile-0.10.1rc538.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
674
+ qontract_reconcile-0.10.1rc538.dist-info/entry_points.txt,sha256=rTjAv28I_CHLM8ID3OPqMI_suoQ9s7tFbim4aYjn9kk,376
675
+ qontract_reconcile-0.10.1rc538.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
676
+ qontract_reconcile-0.10.1rc538.dist-info/RECORD,,
reconcile/cli.py CHANGED
@@ -507,6 +507,30 @@ def trigger_integration(function):
507
507
  return function
508
508
 
509
509
 
510
+ def enable_extended_early_exit(function):
511
+ return click.option(
512
+ "--enable-extended-early-exit/--no-enable-extended-early-exit",
513
+ default=False,
514
+ help="enable extended early exit.",
515
+ )(function)
516
+
517
+
518
+ def extended_early_exit_cache_ttl_seconds(function):
519
+ return click.option(
520
+ "--extended-early-exit-cache-ttl-seconds",
521
+ default=3600,
522
+ help="TTL of extended early exit cache in seconds.",
523
+ )(function)
524
+
525
+
526
+ def log_cached_log_output(function):
527
+ return click.option(
528
+ "--log-cached-log-output/--no-log-cached-log-output",
529
+ default=False,
530
+ help="log the cached log output.",
531
+ )(function)
532
+
533
+
510
534
  def register_faulthandler(fileobj=sys.__stderr__):
511
535
  if fileobj:
512
536
  if not faulthandler.is_enabled():
@@ -1738,6 +1762,9 @@ def terraform_repo(ctx, output_file, gitlab_project_id, gitlab_merge_request_id)
1738
1762
  @enable_deletion(default=False)
1739
1763
  @account_name_multiple
1740
1764
  @exclude_aws_accounts
1765
+ @enable_extended_early_exit
1766
+ @extended_early_exit_cache_ttl_seconds
1767
+ @log_cached_log_output
1741
1768
  @click.option(
1742
1769
  "--light/--full",
1743
1770
  default=False,
@@ -1755,6 +1782,9 @@ def terraform_resources(
1755
1782
  vault_output_path,
1756
1783
  account_name,
1757
1784
  exclude_accounts,
1785
+ enable_extended_early_exit,
1786
+ extended_early_exit_cache_ttl_seconds,
1787
+ log_cached_log_output,
1758
1788
  ):
1759
1789
  import reconcile.terraform_resources
1760
1790
 
@@ -1772,6 +1802,9 @@ def terraform_resources(
1772
1802
  vault_output_path,
1773
1803
  account_name=account_name,
1774
1804
  exclude_accounts=exclude_accounts,
1805
+ enable_extended_early_exit=enable_extended_early_exit,
1806
+ extended_early_exit_cache_ttl_seconds=extended_early_exit_cache_ttl_seconds,
1807
+ log_cached_log_output=log_cached_log_output,
1775
1808
  )
1776
1809
 
1777
1810
 
@@ -1,6 +1,4 @@
1
1
  import logging
2
- import shutil
3
- import sys
4
2
  from collections.abc import (
5
3
  Callable,
6
4
  Iterable,
@@ -11,6 +9,7 @@ from typing import (
11
9
  Collection,
12
10
  Optional,
13
11
  Sequence,
12
+ TypedDict,
14
13
  cast,
15
14
  )
16
15
 
@@ -33,6 +32,10 @@ from reconcile.typed_queries.terraform_namespaces import get_namespaces
33
32
  from reconcile.utils import gql
34
33
  from reconcile.utils.aws_api import AWSApi
35
34
  from reconcile.utils.defer import defer
35
+ from reconcile.utils.extended_early_exit import (
36
+ ExtendedEarlyExitRunnerResult,
37
+ extended_early_exit_run,
38
+ )
36
39
  from reconcile.utils.external_resource_spec import (
37
40
  ExternalResourceSpec,
38
41
  ExternalResourceSpecInventory,
@@ -52,10 +55,12 @@ from reconcile.utils.ocm import OCMMap
52
55
  from reconcile.utils.openshift_resource import OpenshiftResource as OR
53
56
  from reconcile.utils.openshift_resource import ResourceInventory
54
57
  from reconcile.utils.runtime.integration import DesiredStateShardConfig
55
- from reconcile.utils.secret_reader import create_secret_reader
58
+ from reconcile.utils.secret_reader import SecretReaderBase, create_secret_reader
56
59
  from reconcile.utils.semver_helper import make_semver
57
60
  from reconcile.utils.terraform_client import TerraformClient as Terraform
61
+ from reconcile.utils.terrascript_aws_client import TerrascriptClient
58
62
  from reconcile.utils.terrascript_aws_client import TerrascriptClient as Terrascript
63
+ from reconcile.utils.unleash import get_feature_toggle_state
59
64
  from reconcile.utils.vault import (
60
65
  VaultClient,
61
66
  _VaultClient,
@@ -117,18 +122,14 @@ def populate_oc_resources(
117
122
 
118
123
 
119
124
  def fetch_current_state(
120
- dry_run: bool,
121
125
  namespaces: Iterable[NamespaceV1],
122
126
  thread_pool_size: int,
123
127
  internal: Optional[bool],
124
128
  use_jump_host: bool,
125
129
  account_names: Optional[Iterable[str]],
126
- ) -> tuple[ResourceInventory, Optional[OCMap]]:
130
+ secret_reader: SecretReaderBase,
131
+ ) -> tuple[ResourceInventory, OCMap]:
127
132
  ri = ResourceInventory()
128
- if dry_run:
129
- return ri, None
130
- vault_settings = get_app_interface_vault_settings()
131
- secret_reader = create_secret_reader(use_vault=vault_settings.vault)
132
133
  oc_map = init_oc_map_from_namespaces(
133
134
  namespaces=namespaces,
134
135
  integration=QONTRACT_INTEGRATION,
@@ -172,64 +173,74 @@ def init_working_dirs(
172
173
 
173
174
 
174
175
  def filter_accounts_by_name(
175
- accounts: Iterable[Mapping[str, Any]], filter: Iterable[str]
176
- ) -> Collection[Mapping[str, Any]]:
177
- return [ac for ac in accounts if ac["name"] in filter]
176
+ accounts: Iterable[dict[str, Any]], names: Iterable[str]
177
+ ) -> list[dict[str, Any]]:
178
+ return [ac for ac in accounts if ac["name"] in names]
178
179
 
179
180
 
180
181
  def exclude_accounts_by_name(
181
- accounts: Iterable[Mapping[str, Any]], filter: Iterable[str]
182
- ) -> Collection[Mapping[str, Any]]:
183
- return [ac for ac in accounts if ac["name"] not in filter]
182
+ accounts: Iterable[dict[str, Any]], names: Iterable[str]
183
+ ) -> list[dict[str, Any]]:
184
+ return [ac for ac in accounts if ac["name"] not in names]
184
185
 
185
186
 
186
187
  def validate_account_names(
187
188
  accounts: Collection[Mapping[str, Any]], names: Collection[str]
188
189
  ) -> None:
189
- if len(accounts) != len(names):
190
- missing_names = set(names) - {a["name"] for a in accounts}
190
+ if missing_names := set(names) - {a["name"] for a in accounts}:
191
191
  raise ValueError(
192
192
  f"Accounts {missing_names} were provided as arguments, but not found in app-interface. Check your input for typos or for missing AWS account definitions."
193
193
  )
194
194
 
195
195
 
196
- def setup(
196
+ def get_aws_accounts(
197
197
  dry_run: bool,
198
- print_to_file: Optional[str],
199
- thread_pool_size: int,
200
- internal: Optional[bool],
201
- use_jump_host: bool,
202
198
  include_accounts: Optional[Collection[str]],
203
199
  exclude_accounts: Optional[Collection[str]],
204
- ) -> tuple[
205
- ResourceInventory, Optional[OCMap], Terraform, ExternalResourceSpecInventory
206
- ]:
200
+ ) -> list[dict[str, Any]]:
201
+ if exclude_accounts and not dry_run:
202
+ message = "--exclude-accounts is only supported in dry-run mode"
203
+ logging.error(message)
204
+ raise ExcludeAccountsAndDryRunException(message)
205
+
206
+ if exclude_accounts and include_accounts:
207
+ message = "Using --exclude-accounts and --account-name at the same time is not allowed"
208
+ logging.error(message)
209
+ raise ExcludeAccountsAndAccountNameException(message)
210
+
211
+ # If we are not running in dry run we don't want to run with more than one account
212
+ if include_accounts and len(include_accounts) > 1 and not dry_run:
213
+ message = "Running with multiple accounts is only supported in dry-run mode"
214
+ logging.error(message)
215
+ raise MultipleAccountNamesInDryRunException(message)
216
+
207
217
  accounts = queries.get_aws_accounts(terraform_state=True)
208
- if not include_accounts and exclude_accounts:
209
- excluding = filter_accounts_by_name(accounts, exclude_accounts)
210
- validate_account_names(excluding, exclude_accounts)
211
- accounts = exclude_accounts_by_name(accounts, exclude_accounts)
212
- if len(accounts) == 0:
218
+
219
+ if exclude_accounts:
220
+ validate_account_names(accounts, exclude_accounts)
221
+ filtered_accounts = exclude_accounts_by_name(accounts, exclude_accounts)
222
+ if not filtered_accounts:
213
223
  raise ValueError("You have excluded all aws accounts, verify your input")
214
- account_names = tuple(ac["name"] for ac in accounts)
215
- elif include_accounts:
216
- accounts = filter_accounts_by_name(accounts, include_accounts)
224
+ return filtered_accounts
225
+
226
+ if include_accounts:
217
227
  validate_account_names(accounts, include_accounts)
218
- account_names = tuple(a["name"] for a in accounts)
219
- settings = queries.get_app_interface_settings()
228
+ return filter_accounts_by_name(accounts, include_accounts)
220
229
 
221
- # build a resource inventory for all the kube secrets managed by the
222
- # app-interface managed terraform resources
223
- tf_namespaces = get_tf_namespaces(account_names)
224
- if not tf_namespaces:
225
- logging.warning(
226
- "No terraform namespaces found, consider disabling this integration, account names: "
227
- f"{', '.join(account_names)}"
228
- )
229
- ri, oc_map = fetch_current_state(
230
- dry_run, tf_namespaces, thread_pool_size, internal, use_jump_host, account_names
231
- )
230
+ return accounts
231
+
232
+
233
+ def setup(
234
+ accounts: list[dict[str, Any]],
235
+ account_names: set[str],
236
+ tf_namespaces: list[NamespaceV1],
237
+ print_to_file: Optional[str],
238
+ thread_pool_size: int,
239
+ ) -> tuple[Terraform, TerrascriptClient, SecretReaderBase]:
240
+ vault_settings = get_app_interface_vault_settings()
241
+ secret_reader = create_secret_reader(use_vault=vault_settings.vault)
232
242
 
243
+ settings = queries.get_app_interface_settings()
233
244
  # initialize terrascript (scripting engine to generate terraform manifests)
234
245
  ts, working_dirs = init_working_dirs(accounts, thread_pool_size, settings=settings)
235
246
 
@@ -260,7 +271,7 @@ def setup(
260
271
  ts.populate_resources(ocm_map=ocm_map)
261
272
  ts.dump(print_to_file, existing_dirs=working_dirs)
262
273
 
263
- return ri, oc_map, tf, ts.resource_spec_inventory
274
+ return tf, ts, secret_reader
264
275
 
265
276
 
266
277
  def filter_tf_namespaces(
@@ -290,21 +301,6 @@ def filter_tf_namespaces(
290
301
  return tf_namespaces
291
302
 
292
303
 
293
- def cleanup_and_exit(
294
- tf: Optional[Terraform] = None,
295
- status: bool = False,
296
- working_dirs: Optional[Mapping[str, str]] = None,
297
- ) -> None:
298
- if working_dirs is None:
299
- working_dirs = {}
300
- if tf is None:
301
- for wd in working_dirs.values():
302
- shutil.rmtree(wd)
303
- else:
304
- tf.cleanup()
305
- sys.exit(status)
306
-
307
-
308
304
  @retry()
309
305
  def write_outputs_to_vault(
310
306
  vault_path: str, resource_specs: ExternalResourceSpecInventory
@@ -366,65 +362,125 @@ def run(
366
362
  vault_output_path: str = "",
367
363
  account_name: Optional[Sequence[str]] = None,
368
364
  exclude_accounts: Optional[Sequence[str]] = None,
365
+ enable_extended_early_exit: bool = False,
366
+ extended_early_exit_cache_ttl_seconds: int = 3600,
367
+ log_cached_log_output: bool = False,
369
368
  defer: Optional[Callable] = None,
370
369
  ) -> None:
371
- if exclude_accounts and not dry_run:
372
- message = "--exclude-accounts is only supported in dry-run mode"
373
- logging.error(message)
374
- raise ExcludeAccountsAndDryRunException(message)
375
-
376
- if exclude_accounts and account_name:
377
- message = "Using --exclude-accounts and --account-name at the same time is not allowed"
378
- logging.error(message)
379
- raise ExcludeAccountsAndAccountNameException(message)
380
-
381
370
  # account_name is a tuple of account names for more detail go to
382
371
  # https://click.palletsprojects.com/en/8.1.x/options/#multiple-options
383
- account_names = account_name
384
-
385
- # acc_name will prevent type error since account_name is not a str
386
- acc_name: Optional[str] = account_names[0] if account_names else None
387
-
388
- # If we are not running in dry run we don't want to run with more than one account
389
- if account_names and len(account_names) > 1 and not dry_run:
390
- message = "Running with multiple accounts is only supported in dry-run mode"
391
- logging.error(message)
392
- raise MultipleAccountNamesInDryRunException(message)
372
+ accounts = get_aws_accounts(dry_run, account_name, exclude_accounts)
373
+ account_names = {a["name"] for a in accounts}
374
+ tf_namespaces = get_tf_namespaces(account_names)
375
+ if not tf_namespaces:
376
+ logging.warning(
377
+ "No terraform namespaces found, consider disabling this integration, account names: "
378
+ f"{', '.join(account_names)}"
379
+ )
393
380
 
394
- ri, oc_map, tf, resource_specs = setup(
395
- dry_run,
381
+ tf, ts, secret_reader = setup(
382
+ accounts,
383
+ account_names,
384
+ tf_namespaces,
396
385
  print_to_file,
397
386
  thread_pool_size,
398
- internal,
399
- use_jump_host,
400
- account_names,
401
- exclude_accounts,
402
387
  )
403
- publish_metrics(resource_specs, QONTRACT_INTEGRATION)
388
+ if defer:
389
+ defer(tf.cleanup)
404
390
 
405
- if not dry_run and oc_map and defer:
406
- defer(oc_map.cleanup)
391
+ publish_metrics(ts.resource_spec_inventory, QONTRACT_INTEGRATION)
407
392
 
408
393
  if print_to_file:
409
- cleanup_and_exit(tf)
410
- if tf is None:
411
- err = True
412
- cleanup_and_exit(tf, err)
394
+ return
395
+
396
+ runner_params: RunnerParams = dict(
397
+ accounts=accounts,
398
+ account_names=account_names,
399
+ tf_namespaces=tf_namespaces,
400
+ tf=tf,
401
+ ts=ts,
402
+ secret_reader=secret_reader,
403
+ dry_run=dry_run,
404
+ enable_deletion=enable_deletion,
405
+ thread_pool_size=thread_pool_size,
406
+ internal=internal,
407
+ use_jump_host=use_jump_host,
408
+ light=light,
409
+ vault_output_path=vault_output_path,
410
+ defer=defer,
411
+ )
413
412
 
413
+ if enable_extended_early_exit and get_feature_toggle_state(
414
+ "terraform-resources-extended-early-exit",
415
+ default=False,
416
+ ):
417
+ extended_early_exit_run(
418
+ integration=QONTRACT_INTEGRATION,
419
+ integration_version=QONTRACT_INTEGRATION_VERSION,
420
+ dry_run=dry_run,
421
+ cache_source=ts.terraform_configurations(),
422
+ ttl_seconds=extended_early_exit_cache_ttl_seconds,
423
+ logger=logging.getLogger(),
424
+ runner=runner,
425
+ runner_params=runner_params,
426
+ secret_reader=secret_reader,
427
+ log_cached_log_output=log_cached_log_output,
428
+ )
429
+ else:
430
+ runner(**runner_params)
431
+
432
+
433
+ class RunnerParams(TypedDict):
434
+ accounts: list[dict[str, Any]]
435
+ account_names: set[str]
436
+ tf_namespaces: list[NamespaceV1]
437
+ tf: Terraform
438
+ ts: Terrascript
439
+ secret_reader: SecretReaderBase
440
+ dry_run: bool
441
+ enable_deletion: bool
442
+ thread_pool_size: int
443
+ internal: Optional[bool]
444
+ use_jump_host: bool
445
+ light: bool
446
+ vault_output_path: str
447
+ defer: Optional[Callable]
448
+
449
+
450
+ def runner(
451
+ accounts: list[dict[str, Any]],
452
+ account_names: set[str],
453
+ tf_namespaces: list[NamespaceV1],
454
+ tf: Terraform,
455
+ ts: Terrascript,
456
+ secret_reader: SecretReaderBase,
457
+ dry_run: bool,
458
+ enable_deletion: bool = False,
459
+ thread_pool_size: int = 10,
460
+ internal: Optional[bool] = None,
461
+ use_jump_host: bool = True,
462
+ light: bool = False,
463
+ vault_output_path: str = "",
464
+ defer: Optional[Callable] = None,
465
+ ) -> ExtendedEarlyExitRunnerResult:
414
466
  if not light:
415
467
  disabled_deletions_detected, err = tf.plan(enable_deletion)
416
468
  if err:
417
- cleanup_and_exit(tf, err)
469
+ raise RuntimeError("Terraform plan has errors")
418
470
  if disabled_deletions_detected:
419
- cleanup_and_exit(tf, disabled_deletions_detected)
471
+ raise RuntimeError("Terraform plan has disabled deletions detected")
420
472
 
421
473
  if dry_run:
422
- cleanup_and_exit(tf)
474
+ return ExtendedEarlyExitRunnerResult(
475
+ payload=ts.terraform_configurations(),
476
+ applied_count=0,
477
+ )
423
478
 
424
- if not light and tf.should_apply:
479
+ acc_name = accounts[0]["name"] if accounts else None
480
+ if not light and tf.should_apply():
425
481
  err = tf.apply()
426
482
  if err:
427
- cleanup_and_exit(tf, err)
483
+ raise RuntimeError("Terraform apply has errors")
428
484
 
429
485
  if defer:
430
486
  defer(
@@ -437,26 +493,41 @@ def run(
437
493
 
438
494
  # refresh output data after terraform apply
439
495
  tf.populate_terraform_output_secrets(
440
- resource_specs=resource_specs, init_rds_replica_source=True
496
+ resource_specs=ts.resource_spec_inventory, init_rds_replica_source=True
441
497
  )
498
+
499
+ ri, oc_map = fetch_current_state(
500
+ tf_namespaces,
501
+ thread_pool_size,
502
+ internal,
503
+ use_jump_host,
504
+ account_names,
505
+ secret_reader=secret_reader,
506
+ )
507
+ if defer:
508
+ defer(oc_map.cleanup)
442
509
  # populate the resource inventory with latest output data
443
- populate_desired_state(ri, resource_specs)
510
+ populate_desired_state(ri, ts.resource_spec_inventory)
444
511
 
445
512
  ob.publish_metrics(ri, QONTRACT_INTEGRATION)
446
- actions = []
447
- if oc_map:
448
- actions = ob.realize_data(
449
- dry_run, oc_map, ri, thread_pool_size, caller=acc_name
450
- )
513
+ actions = ob.realize_data(
514
+ dry_run,
515
+ oc_map,
516
+ ri,
517
+ thread_pool_size,
518
+ caller=acc_name,
519
+ )
451
520
 
452
521
  if actions and vault_output_path:
453
- write_outputs_to_vault(vault_output_path, resource_specs)
522
+ write_outputs_to_vault(vault_output_path, ts.resource_spec_inventory)
454
523
 
455
524
  if ri.has_error_registered():
456
- err = True
457
- cleanup_and_exit(tf, err)
525
+ raise RuntimeError("Resource inventory has errors registered")
458
526
 
459
- cleanup_and_exit(tf)
527
+ return ExtendedEarlyExitRunnerResult(
528
+ payload=ts.terraform_configurations(),
529
+ applied_count=tf.apply_count + len(actions),
530
+ )
460
531
 
461
532
 
462
533
  def early_exit_desired_state(*args: Any, **kwargs: Any) -> dict[str, Any]:
@@ -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,
@@ -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
tools/qontract_cli.py CHANGED
@@ -2446,8 +2446,8 @@ def early_exit_cache(ctx):
2446
2446
  )
2447
2447
  @click.option(
2448
2448
  "-c",
2449
- "--cache-desired-state",
2450
- help="Cache desired state. It should be a JSON string.",
2449
+ "--cache-source",
2450
+ help="Cache source. It should be a JSON string.",
2451
2451
  required=True,
2452
2452
  )
2453
2453
  @click.pass_context
@@ -2456,14 +2456,14 @@ def early_exit_cache_head(
2456
2456
  integration,
2457
2457
  integration_version,
2458
2458
  dry_run,
2459
- cache_desired_state,
2459
+ cache_source,
2460
2460
  ):
2461
2461
  with EarlyExitCache.build() as cache:
2462
2462
  cache_key = CacheKey(
2463
2463
  integration=integration,
2464
2464
  integration_version=integration_version,
2465
2465
  dry_run=dry_run,
2466
- cache_desired_state=json.loads(cache_desired_state),
2466
+ cache_source=json.loads(cache_source),
2467
2467
  )
2468
2468
  status = cache.head(cache_key)
2469
2469
  print(status)
@@ -2489,8 +2489,8 @@ def early_exit_cache_head(
2489
2489
  )
2490
2490
  @click.option(
2491
2491
  "-c",
2492
- "--cache-desired-state",
2493
- help="Cache desired state. It should be a JSON string.",
2492
+ "--cache-source",
2493
+ help="Cache source. It should be a JSON string.",
2494
2494
  required=True,
2495
2495
  )
2496
2496
  @click.pass_context
@@ -2499,14 +2499,14 @@ def early_exit_cache_get(
2499
2499
  integration,
2500
2500
  integration_version,
2501
2501
  dry_run,
2502
- cache_desired_state,
2502
+ cache_source,
2503
2503
  ):
2504
2504
  with EarlyExitCache.build() as cache:
2505
2505
  cache_key = CacheKey(
2506
2506
  integration=integration,
2507
2507
  integration_version=integration_version,
2508
2508
  dry_run=dry_run,
2509
- cache_desired_state=json.loads(cache_desired_state),
2509
+ cache_source=json.loads(cache_source),
2510
2510
  )
2511
2511
  value = cache.get(cache_key)
2512
2512
  print(value)
@@ -2532,14 +2532,14 @@ def early_exit_cache_get(
2532
2532
  )
2533
2533
  @click.option(
2534
2534
  "-c",
2535
- "--cache-desired-state",
2536
- help="Cache desired state. It should be a JSON string.",
2535
+ "--cache-source",
2536
+ help="Cache source. It should be a JSON string.",
2537
2537
  required=True,
2538
2538
  )
2539
2539
  @click.option(
2540
- "-d",
2541
- "--desired-state",
2542
- help="Desired state. It should be a JSON string.",
2540
+ "-p",
2541
+ "--payload",
2542
+ help="Payload in Cache value. It should be a JSON string.",
2543
2543
  required=True,
2544
2544
  )
2545
2545
  @click.option(
@@ -2568,8 +2568,8 @@ def early_exit_cache_set(
2568
2568
  integration,
2569
2569
  integration_version,
2570
2570
  dry_run,
2571
- cache_desired_state,
2572
- desired_state,
2571
+ cache_source,
2572
+ payload,
2573
2573
  log_output,
2574
2574
  applied_count,
2575
2575
  ttl,
@@ -2579,10 +2579,10 @@ def early_exit_cache_set(
2579
2579
  integration=integration,
2580
2580
  integration_version=integration_version,
2581
2581
  dry_run=dry_run,
2582
- cache_desired_state=json.loads(cache_desired_state),
2582
+ cache_source=json.loads(cache_source),
2583
2583
  )
2584
2584
  cache_value = CacheValue(
2585
- desired_state=json.loads(desired_state),
2585
+ payload=json.loads(payload),
2586
2586
  log_output=log_output,
2587
2587
  applied_count=applied_count,
2588
2588
  )
@@ -84,7 +84,7 @@ def test_early_exit_cache_set(env_vars, mock_queries, mock_early_exit_cache):
84
84
 
85
85
  result = runner.invoke(
86
86
  qontract_cli.early_exit_cache,
87
- "set -i a -v b --no-dry-run -c {} -d {} -l log -t 30",
87
+ "set -i a -v b --no-dry-run -c {} -p {} -l log -t 30",
88
88
  )
89
89
  assert result.exit_code == 0
90
90
  mock_early_exit_cache.build.return_value.__enter__.return_value.set.assert_called()