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.
- {qontract_reconcile-0.10.1rc536.dist-info → qontract_reconcile-0.10.1rc538.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc536.dist-info → qontract_reconcile-0.10.1rc538.dist-info}/RECORD +19 -18
- reconcile/acs_policies.py +16 -6
- reconcile/cli.py +33 -0
- reconcile/terraform_resources.py +182 -111
- reconcile/test/test_acs_policies.py +26 -11
- reconcile/test/test_terraform_resources.py +242 -12
- reconcile/utils/acs/policies.py +10 -0
- reconcile/utils/early_exit_cache.py +5 -5
- reconcile/utils/extended_early_exit.py +177 -0
- reconcile/utils/external_resources.py +2 -1
- reconcile/utils/metrics.py +7 -0
- reconcile/utils/terraform_client.py +10 -4
- reconcile/utils/terrascript_aws_client.py +11 -3
- tools/qontract_cli.py +17 -17
- tools/test/test_qontract_cli.py +1 -1
- {qontract_reconcile-0.10.1rc536.dist-info → qontract_reconcile-0.10.1rc538.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc536.dist-info → qontract_reconcile-0.10.1rc538.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc536.dist-info → qontract_reconcile-0.10.1rc538.dist-info}/top_level.txt +0 -0
@@ -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=
|
45
|
-
ClusterV1(name=
|
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=
|
95
|
-
Scope(cluster=
|
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=
|
110
|
-
Scope(cluster=
|
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":
|
179
|
-
{"cluster":
|
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":
|
223
|
+
"cluster": CLUSTER_ID_ONE,
|
220
224
|
"namespace": "app-interface-stage",
|
221
225
|
"label": None,
|
222
226
|
},
|
223
227
|
{
|
224
|
-
"cluster":
|
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,
|
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,
|
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,
|
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
|
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 =
|
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(
|
274
|
+
mocker.patch(
|
275
|
+
"reconcile.terraform_resources.get_namespaces"
|
276
|
+
).return_value = tf_namespaces
|
267
277
|
|
268
|
-
mocked_ts = mocker.patch(
|
269
|
-
|
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(
|
272
|
-
|
273
|
-
|
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
|
-
|
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
|
+
)
|
reconcile/utils/acs/policies.py
CHANGED
@@ -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,
|
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
|
-
|
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.
|
26
|
+
DeepHash(self.cache_source)[self.cache_source],
|
27
27
|
])
|
28
28
|
|
29
29
|
|
30
30
|
class CacheValue(BaseModel):
|
31
|
-
|
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:
|
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=
|
87
|
+
integration=integration_name,
|
87
88
|
provision_provider=provision_provider,
|
88
89
|
provisioner_name=provisioner_name,
|
89
90
|
provider=provider,
|
reconcile/utils/metrics.py
CHANGED
@@ -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("_", "-")
|