qontract-reconcile 0.10.1rc536__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.
@@ -20,6 +20,10 @@ from reconcile.gql_definitions.acs.acs_policies import (
20
20
  )
21
21
  from reconcile.utils.acs.policies import AcsPolicyApi, Policy, PolicyCondition, Scope
22
22
 
23
+ CLUSTER_NAME_ONE = "app-sre-stage"
24
+ CLUSTER_ID_ONE = "5211d395-5cf7-4185-a1fb-d88f41bc7542"
25
+ CLUSTER_NAME_TWO = "app-sre-prod"
26
+ CLUSTER_ID_TWO = "a217cca7-d85a-4be1-9703-f58866fdbe2d"
23
27
  CUSTOM_POLICY_ONE_NAME = "app-sre-clusters-fixable-cve-7-fixable"
24
28
  CUSTOM_POLICY_ONE_ID = "365d4e71-3241-4448-9f3d-eb0eed1c1820"
25
29
  CUSTOM_POLICY_TWO_NAME = "app-sre-namespaces-severity-critical"
@@ -41,8 +45,8 @@ def query_data_desired_state() -> AcsPolicyQueryData:
41
45
  scope=AcsPolicyScopeClusterV1(
42
46
  level="cluster",
43
47
  clusters=[
44
- ClusterV1(name="app-sre-stage"),
45
- ClusterV1(name="app-sre-prod"),
48
+ ClusterV1(name=CLUSTER_NAME_ONE),
49
+ ClusterV1(name=CLUSTER_NAME_TWO),
46
50
  ],
47
51
  ),
48
52
  conditions=[
@@ -91,8 +95,8 @@ def modeled_acs_policies() -> list[Policy]:
91
95
  notifiers=[JIRA_NOTIFIER_ID],
92
96
  categories=["Vulnerability Management"],
93
97
  scope=[
94
- Scope(cluster="app-sre-prod", namespace=""),
95
- Scope(cluster="app-sre-stage", namespace=""),
98
+ Scope(cluster=CLUSTER_ID_ONE, namespace=""),
99
+ Scope(cluster=CLUSTER_ID_TWO, namespace=""),
96
100
  ],
97
101
  conditions=[
98
102
  PolicyCondition(field_name="CVSS", values=[">=7"], negate=False),
@@ -106,8 +110,8 @@ def modeled_acs_policies() -> list[Policy]:
106
110
  notifiers=[],
107
111
  categories=["DevOps Best Practices", "Vulnerability Management"],
108
112
  scope=[
109
- Scope(cluster="app-sre-prod", namespace="app-interface-production"),
110
- Scope(cluster="app-sre-stage", namespace="app-interface-stage"),
113
+ Scope(cluster=CLUSTER_ID_ONE, namespace="app-interface-stage"),
114
+ Scope(cluster=CLUSTER_ID_TWO, namespace="app-interface-production"),
111
115
  ],
112
116
  conditions=[
113
117
  PolicyCondition(
@@ -175,8 +179,8 @@ def api_response_policies_specific() -> list[Any]:
175
179
  "eventSource": "NOT_APPLICABLE",
176
180
  "exclusions": [],
177
181
  "scope": [
178
- {"cluster": "app-sre-stage", "namespace": "", "label": None},
179
- {"cluster": "app-sre-prod", "namespace": "", "label": None},
182
+ {"cluster": CLUSTER_ID_ONE, "namespace": "", "label": None},
183
+ {"cluster": CLUSTER_ID_TWO, "namespace": "", "label": None},
180
184
  ],
181
185
  "severity": "HIGH_SEVERITY",
182
186
  "enforcementActions": [],
@@ -216,12 +220,12 @@ def api_response_policies_specific() -> list[Any]:
216
220
  "exclusions": [],
217
221
  "scope": [
218
222
  {
219
- "cluster": "app-sre-stage",
223
+ "cluster": CLUSTER_ID_ONE,
220
224
  "namespace": "app-interface-stage",
221
225
  "label": None,
222
226
  },
223
227
  {
224
- "cluster": "app-sre-prod",
228
+ "cluster": CLUSTER_ID_TWO,
225
229
  "namespace": "app-interface-production",
226
230
  "label": None,
227
231
  },
@@ -257,11 +261,20 @@ def api_response_list_notifiers() -> list[AcsPolicyApi.NotifierIdentifiers]:
257
261
  ]
258
262
 
259
263
 
264
+ @pytest.fixture
265
+ def api_response_list_clusters() -> list[AcsPolicyApi.ClusterIdentifiers]:
266
+ return [
267
+ AcsPolicyApi.ClusterIdentifiers(id=CLUSTER_ID_ONE, name=CLUSTER_NAME_ONE),
268
+ AcsPolicyApi.ClusterIdentifiers(id=CLUSTER_ID_TWO, name=CLUSTER_NAME_TWO),
269
+ ]
270
+
271
+
260
272
  def test_get_desired_state(
261
273
  mocker: MockerFixture,
262
274
  query_data_desired_state: AcsPolicyQueryData,
263
275
  modeled_acs_policies: list[Policy],
264
276
  api_response_list_notifiers: list[AcsPolicyApi.NotifierIdentifiers],
277
+ api_response_list_clusters: list[AcsPolicyApi.ClusterIdentifiers],
265
278
  ) -> None:
266
279
  query_func = mocker.patch(
267
280
  "reconcile.gql_definitions.acs.acs_policies.query", autospec=True
@@ -270,7 +283,9 @@ def test_get_desired_state(
270
283
 
271
284
  integration = AcsPoliciesIntegration()
272
285
  result = integration.get_desired_state(
273
- query_func=query_func, notifiers=api_response_list_notifiers
286
+ query_func=query_func,
287
+ notifiers=api_response_list_notifiers,
288
+ clusters=api_response_list_clusters,
274
289
  )
275
290
  assert result == modeled_acs_policies
276
291
 
@@ -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
+ )
@@ -151,3 +151,13 @@ class AcsPolicyApi(AcsBaseApi):
151
151
  self.NotifierIdentifiers(id=c["id"], name=c["name"])
152
152
  for c in self.generic_request("/v1/notifiers", "GET").json()["notifiers"]
153
153
  ]
154
+
155
+ class ClusterIdentifiers(BaseModel):
156
+ id: str
157
+ name: str
158
+
159
+ def list_clusters(self) -> list[ClusterIdentifiers]:
160
+ return [
161
+ self.ClusterIdentifiers(id=c["id"], name=c["name"])
162
+ for c in self.generic_request("/v1/clusters", "GET").json()["clusters"]
163
+ ]
@@ -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("_", "-")