qontract-reconcile 0.10.1rc734__py3-none-any.whl → 0.10.1rc736__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.1rc734
3
+ Version: 0.10.1rc736
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
@@ -1,4 +1,5 @@
1
1
  reconcile/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ reconcile/acs_notifiers.py,sha256=1UBRTGsLrMUxpgm3WGceNyCmY9MZwUht4t26eIMWOLU,5589
2
3
  reconcile/acs_policies.py,sha256=1iRYmMdz0YtqyQgA9O0uGQdmMKUCCe-ApRa6LIEAdps,8769
3
4
  reconcile/acs_rbac.py,sha256=JEDevU4AdhTjMW-fAnNG3iw6Od5tYxGuYSirmu9KurI,22657
4
5
  reconcile/aws_ami_share.py,sha256=eeu0TI3M5yyUaozyAq_aW3tir-9be4YFguOXvIvKHSo,3757
@@ -9,7 +10,7 @@ reconcile/aws_iam_password_reset.py,sha256=NwErtrqgBiXr7eGCAHdtGGOx0S7-4JnSc29Ie
9
10
  reconcile/aws_support_cases_sos.py,sha256=Jk6_XjDeJSYxgRGqcEAOcynt9qJF2r5HPIPcSKmoBv8,2974
10
11
  reconcile/blackbox_exporter_endpoint_monitoring.py,sha256=W_VJagnsJR1v5oqjlI3RJJE0_nhtJ0m81RS8zWA5u5c,3538
11
12
  reconcile/checkpoint.py,sha256=R2WFXUXLTB4sWMi4GeA4eegsuf_1-Q4vH8M0Toh3Ij4,5036
12
- reconcile/cli.py,sha256=lAZ68_w6IcvXfB6C8D97DtdRlpodqjrCGLLYGvRUkSs,96887
13
+ reconcile/cli.py,sha256=XBUVgDXU9vIUWu4F3qkNN39FlMH7XN8kaGjzfShPWGM,97162
13
14
  reconcile/closedbox_endpoint_monitoring_base.py,sha256=SMhkcQqprWvThrIJa3U_3uh5w1h-alleW1QnCJFY4Qw,4909
14
15
  reconcile/cluster_deployment_mapper.py,sha256=2Ah-nu-Mdig0pjuiZl_XLrmVAjYzFjORR3dMlCgkmw0,2352
15
16
  reconcile/dashdotdb_base.py,sha256=a5aPLVxyqPSbjdB0Ty-uliOtxwvEbbEljHJKxdK3-Zk,4813
@@ -185,7 +186,7 @@ reconcile/glitchtip_project_dsn/integration.py,sha256=qt2a33FOUUlCyonSHm3nUb7pNX
185
186
  reconcile/gql_definitions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
186
187
  reconcile/gql_definitions/acs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
187
188
  reconcile/gql_definitions/acs/acs_instances.py,sha256=L91WW9LbhJbBSrECqShQpFtjoBOsmNIYLRpMbx1io5o,2181
188
- reconcile/gql_definitions/acs/acs_policies.py,sha256=Z6Z7duvS9W4cbciBED4oK40Vg9QyYti3zXvoEXM-fak,4422
189
+ reconcile/gql_definitions/acs/acs_policies.py,sha256=negfb87RDVH7WT1qiouD8ywfgH4Iumsv9Q2bf9rbjcA,7089
189
190
  reconcile/gql_definitions/acs/acs_rbac.py,sha256=cZsIlCWliPQdQHgmBsIMx54fJNOtkdRXLzmOKZmJNHk,3009
190
191
  reconcile/gql_definitions/advanced_upgrade_service/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
191
192
  reconcile/gql_definitions/advanced_upgrade_service/aus_clusters.py,sha256=zrZCHaxkffdX2bvFUt1Hd_czViI3v3FPhZz5vJQ73jI,4301
@@ -285,6 +286,8 @@ reconcile/gql_definitions/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JC
285
286
  reconcile/gql_definitions/integrations/integrations.py,sha256=LfpgVbCCCk20ohwP5pDea5fwxMFGrcgE6J_WHBuGqek,11595
286
287
  reconcile/gql_definitions/jenkins_configs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
287
288
  reconcile/gql_definitions/jenkins_configs/jenkins_configs.py,sha256=0nMkH0G-AjQwu53fqHykth6X6jjbHdW2hBp5n7N-r24,2766
289
+ reconcile/gql_definitions/jira/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
290
+ reconcile/gql_definitions/jira/jira_servers.py,sha256=i8_P30m-r5Ek6TMgZpqN9fSChDcOf9nLkWD966CXdNw,2102
288
291
  reconcile/gql_definitions/jira_permissions_validator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
289
292
  reconcile/gql_definitions/jira_permissions_validator/jira_boards_for_permissions_validator.py,sha256=7p-GA-dGeuouUcAvOIMBbgGJJtIXPG5Jx4n024to97M,3856
290
293
  reconcile/gql_definitions/jumphosts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -328,8 +331,8 @@ reconcile/gql_definitions/status_board/status_board.py,sha256=vHEzncabujkqbjJ-ib
328
331
  reconcile/gql_definitions/statuspage/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
329
332
  reconcile/gql_definitions/statuspage/statuspages.py,sha256=gxDb42H93nwtBg7oFRb6Gk9pbAZpsWk_y4Y0s3_g3nE,3520
330
333
  reconcile/gql_definitions/templating/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
331
- reconcile/gql_definitions/templating/template_collection.py,sha256=QYqEsy8BBdX9GK4SuOdSe_hqOHK4IHNz9eJ5P2f4TRQ,3007
332
- reconcile/gql_definitions/templating/templates.py,sha256=ujPPFZm9BbkRrxkcR7ZyReDYfFem4uxONYoPuzgiBTc,2735
334
+ reconcile/gql_definitions/templating/template_collection.py,sha256=lS0vzEKV2ZrzOqOEriqpy0yBgKjb2Ftrzgx6PIH46_4,3310
335
+ reconcile/gql_definitions/templating/templates.py,sha256=ejAvQ13zfNMQTz3FWtRUic6dSvio3aAgBKEqt600hbk,2821
333
336
  reconcile/gql_definitions/terraform_cloudflare_dns/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
334
337
  reconcile/gql_definitions/terraform_cloudflare_dns/app_interface_cloudflare_dns_settings.py,sha256=eyGX9HcTF6MZbOYZ6Kl6Mg3k6nJTUtwqs9gDxBP_8Dk,1920
335
338
  reconcile/gql_definitions/terraform_cloudflare_dns/terraform_cloudflare_zones.py,sha256=uVZYu5EUcvdAQYBK5YKD0mjoMKDb5inSuCJrrOD5KpE,5704
@@ -416,16 +419,17 @@ reconcile/templates/jira-checkpoint-missinginfo.j2,sha256=c_Vvg-lEENsB3tgxm9B6Y9
416
419
  reconcile/templates/rosa-classic-cluster-creation.sh.j2,sha256=0UHfYtXRVJqP07VJQx456cRI6EbZNBgamtP_8nb4WPY,2353
417
420
  reconcile/templates/rosa-hcp-cluster-creation.sh.j2,sha256=O7Bf3WQIJhsZoEqaYA0wRktUO4yXXCb4BQkuvvp-C80,2385
418
421
  reconcile/templating/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
419
- reconcile/templating/renderer.py,sha256=JUUzXGXzhcq-bhj81se7lLWgVfrUIEpO5vh6JgppbrI,10178
420
- reconcile/templating/validator.py,sha256=QGH2VSk7sVBuojhk9quRAbj_XBykHN-KZ53DbEneUJs,4391
422
+ reconcile/templating/renderer.py,sha256=2FWbnefT2siozQpXXkuvVKUo6cePMqLY4BMYpqXg6xM,10652
423
+ reconcile/templating/validator.py,sha256=pvDEc6veznEZzjypkoRJUGMMFLWosU-zd7i3j7JeNjE,4670
421
424
  reconcile/templating/lib/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
422
- reconcile/templating/lib/merge_request_manager.py,sha256=El3ufdVjHP4EW-LztX7zPiI9syJBJ6ETGRmiM9K4Nqw,5112
423
- reconcile/templating/lib/model.py,sha256=TYiH2xL63awd30U-fkZDLEzuxkLdUEgzJ913DEzpFoM,265
425
+ reconcile/templating/lib/merge_request_manager.py,sha256=JUkfF3smaQ8onzKF5F7UpmA7MWaQpftANy6dDo1FCug,5464
426
+ reconcile/templating/lib/model.py,sha256=fb6FYYLQjmoh2DjVKO7TEWCuDPf1Q34xmOx0M9Z07ek,324
424
427
  reconcile/templating/lib/rendering.py,sha256=_BVQ2gqip8K1AgLYfaTWh8NKJFTW6VjUZ6rBI_GH30E,5061
425
428
  reconcile/test/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
426
429
  reconcile/test/conftest.py,sha256=rQousYrxUz-EwAIbsYO6bIwR1B4CrOz9y_zaUVo2lfI,4466
427
430
  reconcile/test/fixtures.py,sha256=9SDWAUlSd1rCx7z3GhULHcpr-I6FyCsXxaFAZIqYQsQ,591
428
- reconcile/test/test_acs_policies.py,sha256=pffUzH4IHKuXntvGMi-iV0Epg4YsCBF2G2-R9nYIt40,15699
431
+ reconcile/test/test_acs_notifiers.py,sha256=LIVXu7EBlfKh5CWgR5jcA0vJ4IhUtxkZM6-1im204rc,12865
432
+ reconcile/test/test_acs_policies.py,sha256=9TPGbF4mS2B18S7ldibZrugztaTQvcrBFvzs5eXbN-E,19211
429
433
  reconcile/test/test_acs_rbac.py,sha256=lvNd8GY0-GHzcOdOn13QWdrqbBXXKzNT7EEDHNH7cjM,28272
430
434
  reconcile/test/test_aggregated_list.py,sha256=iiWitQuNYC58aimWaiBoE4NROHjr1NCgQ91MnHEG_Ro,6412
431
435
  reconcile/test/test_amtool.py,sha256=vxRhGieeydMBOb9UI2ziMHjJa8puMeGNsUhGhy-yMnk,1032
@@ -655,7 +659,8 @@ reconcile/utils/vault.py,sha256=S0eHqvZ9N3fya1E8YDaUffEvLk_fdtpzL4rvWn6f828,1499
655
659
  reconcile/utils/vaultsecretref.py,sha256=3Ed2uBy36TzSvL0B-l4FoWQqB2SbBKDKEuUPIO608Bo,931
656
660
  reconcile/utils/vcs.py,sha256=o1r0n_IrU2El75CED_6sjR2GZGM-exuWsj5F7jONaMU,6779
657
661
  reconcile/utils/acs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
658
- reconcile/utils/acs/base.py,sha256=8qZhCYteLCe3xSIeiNj-HXm6J0nBLFGBbZ7b7xXlA6I,2381
662
+ reconcile/utils/acs/base.py,sha256=kjcxLGIMe8oLNFOxZ_bDcClFqGCL7Auwug5is0yNGbw,2555
663
+ reconcile/utils/acs/notifiers.py,sha256=LfWw9LGq7hA90A69n8Ie9f-NozvCGdYwXEXRLIc17rY,3735
659
664
  reconcile/utils/acs/policies.py,sha256=_jAz6cv8KRYtDsXjGoJgNbD8_9PUa5LSwwVlpK4A_cQ,5505
660
665
  reconcile/utils/acs/rbac.py,sha256=ugsLM9Pb7FbUbdq85E3VzXGMaB9ZovXob7tdWCxwqZ8,8808
661
666
  reconcile/utils/aws_api_typed/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -765,8 +770,8 @@ tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvf
765
770
  tools/test/test_qontract_cli.py,sha256=w2l4BHB09k1d-BGJ1jBUNCqDv7zkqYrMHojQXg-21kQ,4155
766
771
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
767
772
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
768
- qontract_reconcile-0.10.1rc734.dist-info/METADATA,sha256=4yl8FRQHqk6s4yWQgBGblLVlgOJiOKeSuEo-S5mcl8I,2382
769
- qontract_reconcile-0.10.1rc734.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
770
- qontract_reconcile-0.10.1rc734.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
771
- qontract_reconcile-0.10.1rc734.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
772
- qontract_reconcile-0.10.1rc734.dist-info/RECORD,,
773
+ qontract_reconcile-0.10.1rc736.dist-info/METADATA,sha256=HGDJxGb77YYojenQNeOYhLfp3EOLLqkOwx8Tat8ONWA,2382
774
+ qontract_reconcile-0.10.1rc736.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
775
+ qontract_reconcile-0.10.1rc736.dist-info/entry_points.txt,sha256=rIxI5zWtHNlfpDeq1a7pZXAPoqf7HG32KMTN3MeWK_8,429
776
+ qontract_reconcile-0.10.1rc736.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
777
+ qontract_reconcile-0.10.1rc736.dist-info/RECORD,,
@@ -0,0 +1,156 @@
1
+ import logging
2
+ from typing import Any
3
+
4
+ import reconcile.gql_definitions.acs.acs_policies as gql_acs_policies
5
+ from reconcile.gql_definitions.jira.jira_servers import (
6
+ JiraServerV1,
7
+ )
8
+ from reconcile.gql_definitions.jira.jira_servers import (
9
+ query as query_jira_servers,
10
+ )
11
+ from reconcile.utils import gql
12
+ from reconcile.utils.acs.notifiers import (
13
+ AcsNotifiersApi,
14
+ JiraCredentials,
15
+ JiraNotifier,
16
+ SeverityPriorityMapping,
17
+ )
18
+ from reconcile.utils.differ import diff_iterables
19
+ from reconcile.utils.disabled_integrations import integration_is_enabled
20
+ from reconcile.utils.runtime.integration import (
21
+ NoParams,
22
+ QontractReconcileIntegration,
23
+ )
24
+ from reconcile.utils.semver_helper import make_semver
25
+
26
+
27
+ class AcsNotifiersIntegration(QontractReconcileIntegration[NoParams]):
28
+ def __init__(self) -> None:
29
+ super().__init__(NoParams())
30
+ self.qontract_integration = "acs-notifiers"
31
+ self.qontract_integration_version = make_semver(0, 1, 0)
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self.qontract_integration
36
+
37
+ def _get_escalation_policies(
38
+ self, acs_policies: list[gql_acs_policies.AcsPolicyV1]
39
+ ) -> list[gql_acs_policies.AppEscalationPolicyV1]:
40
+ return list(
41
+ {
42
+ p.integrations.notifiers.jira.escalation_policy.name: p.integrations.notifiers.jira.escalation_policy
43
+ for p in acs_policies
44
+ if p.integrations
45
+ and p.integrations.notifiers
46
+ and p.integrations.notifiers.jira
47
+ and integration_is_enabled(
48
+ self.qontract_integration,
49
+ p.integrations.notifiers.jira.escalation_policy.channels.jira_board[
50
+ 0
51
+ ],
52
+ )
53
+ }.values()
54
+ )
55
+
56
+ def _build_jira_notifier(
57
+ self, escalation_policy: gql_acs_policies.AppEscalationPolicyV1
58
+ ) -> JiraNotifier:
59
+ jira_board = escalation_policy.channels.jira_board[0]
60
+
61
+ custom_fields: dict[str, Any] = {}
62
+ if jira_board.issue_security_id:
63
+ custom_fields["security"] = {"id": jira_board.issue_security_id}
64
+ if escalation_policy.channels.jira_component:
65
+ custom_fields["components"] = [
66
+ {"name": escalation_policy.channels.jira_component}
67
+ ]
68
+ if escalation_policy.channels.jira_labels:
69
+ custom_fields["labels"] = escalation_policy.channels.jira_labels
70
+
71
+ item = JiraNotifier(
72
+ name=f"jira-{escalation_policy.name}",
73
+ board=jira_board.name,
74
+ url=jira_board.server.server_url,
75
+ issue_type=jira_board.issue_type or "Task",
76
+ severity_priority_mappings=sorted(
77
+ [
78
+ SeverityPriorityMapping(**vars(sp))
79
+ for sp in jira_board.severity_priority_mappings.mappings
80
+ ],
81
+ key=lambda m: m.severity,
82
+ ),
83
+ custom_fields=custom_fields,
84
+ )
85
+
86
+ return item
87
+
88
+ def get_desired_state(
89
+ self, acs_policies: list[gql_acs_policies.AcsPolicyV1]
90
+ ) -> list[JiraNotifier]:
91
+ return [
92
+ self._build_jira_notifier(ep)
93
+ for ep in self._get_escalation_policies(acs_policies)
94
+ ]
95
+
96
+ def get_jira_credentials(
97
+ self, jira_servers: list[JiraServerV1]
98
+ ) -> dict[str, JiraCredentials]:
99
+ return {
100
+ server.server_url: JiraCredentials(
101
+ url=server.server_url,
102
+ username=server.username,
103
+ token=self.secret_reader.read_secret(server.token),
104
+ )
105
+ for server in jira_servers
106
+ }
107
+
108
+ def reconcile(
109
+ self,
110
+ current_state: list[JiraNotifier],
111
+ desired_state: list[JiraNotifier],
112
+ acs_api: AcsNotifiersApi,
113
+ jira_credentials: dict[str, JiraCredentials],
114
+ dry_run: bool,
115
+ ) -> None:
116
+ diff = diff_iterables(
117
+ current=current_state, desired=desired_state, key=lambda x: x.name
118
+ )
119
+ for a in diff.add.values():
120
+ logging.info(f"Create Jira notifier: {a.name}")
121
+ if not dry_run:
122
+ acs_api.create_jira_notifier(
123
+ a,
124
+ jira_credentials=jira_credentials[a.url],
125
+ )
126
+ for c in diff.change.values():
127
+ logging.info(f"Update Jira notifier: {c.desired.name}")
128
+ if not dry_run:
129
+ acs_api.update_jira_notifier(
130
+ c.desired,
131
+ jira_credentials=jira_credentials[c.desired.url],
132
+ )
133
+ for d in diff.delete.values():
134
+ logging.info(f"Delete Jira notifier: {d.name}")
135
+ if not dry_run:
136
+ acs_api.delete_jira_notifier(d)
137
+
138
+ def run(
139
+ self,
140
+ dry_run: bool,
141
+ ) -> None:
142
+ gql_api_query = gql.get_api().query
143
+ jira_credentials = self.get_jira_credentials(
144
+ query_jira_servers(query_func=gql_api_query).jira_servers or []
145
+ )
146
+ desired_state = self.get_desired_state(
147
+ gql_acs_policies.query(query_func=gql_api_query).acs_policies or []
148
+ )
149
+ instance = AcsNotifiersApi.get_acs_instance(query_func=gql_api_query)
150
+ with AcsNotifiersApi(
151
+ url=instance.url, token=self.secret_reader.read_secret(instance.credentials)
152
+ ) as acs_api:
153
+ current_state = acs_api.get_jira_notifiers()
154
+ self.reconcile(
155
+ desired_state, current_state, acs_api, jira_credentials, dry_run
156
+ )
reconcile/cli.py CHANGED
@@ -3441,6 +3441,17 @@ def acs_policies(ctx):
3441
3441
  )
3442
3442
 
3443
3443
 
3444
+ @integration.command(short_help="Manages RHACS notifier configurations")
3445
+ @click.pass_context
3446
+ def acs_notifiers(ctx):
3447
+ from reconcile import acs_notifiers
3448
+
3449
+ run_class_integration(
3450
+ integration=acs_notifiers.AcsNotifiersIntegration(),
3451
+ ctx=ctx.obj,
3452
+ )
3453
+
3454
+
3444
3455
  @integration.command(short_help="Automate Deadmanssnitch Creation/Deletion")
3445
3456
  @click.pass_context
3446
3457
  def deadmanssnitch(ctx):
@@ -25,6 +25,37 @@ query AcsPolicy {
25
25
  description
26
26
  severity
27
27
  notifiers
28
+ integrations {
29
+ notifiers {
30
+ jira {
31
+ escalationPolicy {
32
+ name
33
+ channels {
34
+ jiraBoard {
35
+ name
36
+ server {
37
+ serverUrl
38
+ }
39
+ severityPriorityMappings {
40
+ name
41
+ mappings {
42
+ severity
43
+ priority
44
+ }
45
+ }
46
+ issueType
47
+ issueSecurityId
48
+ disable {
49
+ integrations
50
+ }
51
+ }
52
+ jiraComponent
53
+ jiraLabels
54
+ }
55
+ }
56
+ }
57
+ }
58
+ }
28
59
  categories
29
60
  scope {
30
61
  level
@@ -74,6 +105,56 @@ class ConfiguredBaseModel(BaseModel):
74
105
  extra=Extra.forbid
75
106
 
76
107
 
108
+ class JiraServerV1(ConfiguredBaseModel):
109
+ server_url: str = Field(..., alias="serverUrl")
110
+
111
+
112
+ class SeverityPriorityMappingV1(ConfiguredBaseModel):
113
+ severity: str = Field(..., alias="severity")
114
+ priority: str = Field(..., alias="priority")
115
+
116
+
117
+ class JiraSeverityPriorityMappingsV1(ConfiguredBaseModel):
118
+ name: str = Field(..., alias="name")
119
+ mappings: list[SeverityPriorityMappingV1] = Field(..., alias="mappings")
120
+
121
+
122
+ class DisableJiraBoardAutomationsV1(ConfiguredBaseModel):
123
+ integrations: Optional[list[str]] = Field(..., alias="integrations")
124
+
125
+
126
+ class JiraBoardV1(ConfiguredBaseModel):
127
+ name: str = Field(..., alias="name")
128
+ server: JiraServerV1 = Field(..., alias="server")
129
+ severity_priority_mappings: JiraSeverityPriorityMappingsV1 = Field(..., alias="severityPriorityMappings")
130
+ issue_type: Optional[str] = Field(..., alias="issueType")
131
+ issue_security_id: Optional[str] = Field(..., alias="issueSecurityId")
132
+ disable: Optional[DisableJiraBoardAutomationsV1] = Field(..., alias="disable")
133
+
134
+
135
+ class AppEscalationPolicyChannelsV1(ConfiguredBaseModel):
136
+ jira_board: list[JiraBoardV1] = Field(..., alias="jiraBoard")
137
+ jira_component: Optional[str] = Field(..., alias="jiraComponent")
138
+ jira_labels: Optional[list[str]] = Field(..., alias="jiraLabels")
139
+
140
+
141
+ class AppEscalationPolicyV1(ConfiguredBaseModel):
142
+ name: str = Field(..., alias="name")
143
+ channels: AppEscalationPolicyChannelsV1 = Field(..., alias="channels")
144
+
145
+
146
+ class AcsPolicyIntegrationNotifierJiraV1(ConfiguredBaseModel):
147
+ escalation_policy: AppEscalationPolicyV1 = Field(..., alias="escalationPolicy")
148
+
149
+
150
+ class AcsPolicyIntegrationNotifiersV1(ConfiguredBaseModel):
151
+ jira: Optional[AcsPolicyIntegrationNotifierJiraV1] = Field(..., alias="jira")
152
+
153
+
154
+ class AcsPolicyIntegrationsV1(ConfiguredBaseModel):
155
+ notifiers: Optional[AcsPolicyIntegrationNotifiersV1] = Field(..., alias="notifiers")
156
+
157
+
77
158
  class AcsPolicyScopeV1(ConfiguredBaseModel):
78
159
  level: str = Field(..., alias="level")
79
160
 
@@ -131,6 +212,7 @@ class AcsPolicyV1(ConfiguredBaseModel):
131
212
  description: Optional[str] = Field(..., alias="description")
132
213
  severity: str = Field(..., alias="severity")
133
214
  notifiers: Optional[list[str]] = Field(..., alias="notifiers")
215
+ integrations: Optional[AcsPolicyIntegrationsV1] = Field(..., alias="integrations")
134
216
  categories: list[str] = Field(..., alias="categories")
135
217
  scope: Union[AcsPolicyScopeClusterV1, AcsPolicyScopeNamespaceV1, AcsPolicyScopeV1] = Field(..., alias="scope")
136
218
  conditions: list[Union[AcsPolicyConditionsCvssV1, AcsPolicyConditionsSeverityV1, AcsPolicyConditionsImageTagV1, AcsPolicyConditionsCveV1, AcsPolicyConditionsImageAgeV1, AcsPolicyConditionsV1]] = Field(..., alias="conditions")
File without changes
@@ -0,0 +1,76 @@
1
+ """
2
+ Generated by qenerate plugin=pydantic_v1. DO NOT MODIFY MANUALLY!
3
+ """
4
+ from collections.abc import Callable # noqa: F401 # pylint: disable=W0611
5
+ from datetime import datetime # noqa: F401 # pylint: disable=W0611
6
+ from enum import Enum # noqa: F401 # pylint: disable=W0611
7
+ from typing import ( # noqa: F401 # pylint: disable=W0611
8
+ Any,
9
+ Optional,
10
+ Union,
11
+ )
12
+
13
+ from pydantic import ( # noqa: F401 # pylint: disable=W0611
14
+ BaseModel,
15
+ Extra,
16
+ Field,
17
+ Json,
18
+ )
19
+
20
+
21
+ DEFINITION = """
22
+ query JiraServers {
23
+ jira_servers: jira_servers_v1 {
24
+ serverUrl
25
+ username
26
+ token {
27
+ path
28
+ version
29
+ field
30
+ format
31
+ }
32
+ }
33
+ }
34
+ """
35
+
36
+
37
+ class ConfiguredBaseModel(BaseModel):
38
+ class Config:
39
+ smart_union=True
40
+ extra=Extra.forbid
41
+
42
+
43
+ class VaultSecretV1(ConfiguredBaseModel):
44
+ path: str = Field(..., alias="path")
45
+ version: Optional[int] = Field(..., alias="version")
46
+ field: str = Field(..., alias="field")
47
+ q_format: Optional[str] = Field(..., alias="format")
48
+
49
+
50
+ class JiraServerV1(ConfiguredBaseModel):
51
+ server_url: str = Field(..., alias="serverUrl")
52
+ username: str = Field(..., alias="username")
53
+ token: VaultSecretV1 = Field(..., alias="token")
54
+
55
+
56
+ class JiraServersQueryData(ConfiguredBaseModel):
57
+ jira_servers: Optional[list[JiraServerV1]] = Field(..., alias="jira_servers")
58
+
59
+
60
+ def query(query_func: Callable, **kwargs: Any) -> JiraServersQueryData:
61
+ """
62
+ This is a convenience function which queries and parses the data into
63
+ concrete types. It should be compatible with most GQL clients.
64
+ You do not have to use it to consume the generated data classes.
65
+ Alternatively, you can also mime and alternate the behavior
66
+ of this function in the caller.
67
+
68
+ Parameters:
69
+ query_func (Callable): Function which queries your GQL Server
70
+ kwargs: optional arguments that will be passed to the query function
71
+
72
+ Returns:
73
+ JiraServersQueryData: queried data parsed into generated classes
74
+ """
75
+ raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
76
+ return JiraServersQueryData(**raw_data)
@@ -22,7 +22,9 @@ DEFINITION = """
22
22
  query TemplateCollection_v1 {
23
23
  template_collection_v1 {
24
24
  name
25
+ additionalMrLabels
25
26
  description
27
+ enableAutoApproval
26
28
  variables {
27
29
  static
28
30
  dynamic {
@@ -32,6 +34,7 @@ query TemplateCollection_v1 {
32
34
  }
33
35
  templates {
34
36
  name
37
+ autoApproved
35
38
  condition
36
39
  targetPath
37
40
  patch {
@@ -68,6 +71,7 @@ class TemplatePatchV1(ConfiguredBaseModel):
68
71
 
69
72
  class TemplateV1(ConfiguredBaseModel):
70
73
  name: str = Field(..., alias="name")
74
+ auto_approved: Optional[bool] = Field(..., alias="autoApproved")
71
75
  condition: Optional[str] = Field(..., alias="condition")
72
76
  target_path: str = Field(..., alias="targetPath")
73
77
  patch: Optional[TemplatePatchV1] = Field(..., alias="patch")
@@ -76,7 +80,9 @@ class TemplateV1(ConfiguredBaseModel):
76
80
 
77
81
  class TemplateCollectionV1(ConfiguredBaseModel):
78
82
  name: str = Field(..., alias="name")
83
+ additional_mr_labels: Optional[list[str]] = Field(..., alias="additionalMrLabels")
79
84
  description: str = Field(..., alias="description")
85
+ enable_auto_approval: Optional[bool] = Field(..., alias="enableAutoApproval")
80
86
  variables: Optional[TemplateCollectionVariablesV1] = Field(..., alias="variables")
81
87
  templates: list[TemplateV1] = Field(..., alias="templates")
82
88
 
@@ -22,6 +22,7 @@ DEFINITION = """
22
22
  query Templatev1 {
23
23
  template_v1 {
24
24
  name
25
+ autoApproved
25
26
  condition
26
27
  patch {
27
28
  path
@@ -64,6 +65,7 @@ class TemplateTestV1(ConfiguredBaseModel):
64
65
 
65
66
  class TemplateV1(ConfiguredBaseModel):
66
67
  name: str = Field(..., alias="name")
68
+ auto_approved: Optional[bool] = Field(..., alias="autoApproved")
67
69
  condition: Optional[str] = Field(..., alias="condition")
68
70
  patch: Optional[TemplatePatchV1] = Field(..., alias="patch")
69
71
  target_path: str = Field(..., alias="targetPath")
@@ -13,6 +13,7 @@ from reconcile.utils.merge_request_manager.parser import (
13
13
  Parser,
14
14
  )
15
15
  from reconcile.utils.mr import MergeRequestBase
16
+ from reconcile.utils.mr.labels import AUTO_MERGE
16
17
  from reconcile.utils.vcs import VCS
17
18
 
18
19
  DATA_SEPARATOR = (
@@ -120,16 +121,23 @@ class TemplateRenderingMR(MergeRequestBase):
120
121
  )
121
122
 
122
123
 
124
+ class MrData(BaseModel):
125
+ data: list[TemplateOutput]
126
+ auto_approved: bool
127
+
128
+
123
129
  class MergeRequestManager(MergeRequestManagerBase[TemplateInfo]):
124
130
  def __init__(self, vcs: VCS, parser: Parser):
125
131
  super().__init__(vcs, parser, TR_LABEL)
126
132
 
127
- def create_merge_request(self, output: list[TemplateOutput]) -> None:
133
+ def create_merge_request(self, data: MrData) -> None:
128
134
  if not self._housekeeping_ran:
129
135
  self.housekeeping()
130
136
 
131
- collections = {o.input.collection for o in output if o.input}
132
- collection_hashes = {o.input.collection_hash for o in output if o.input}
137
+ output = data.data
138
+ collections = {o.input.collection for o in output}
139
+ collection_hashes = {o.input.collection_hash for o in output}
140
+ additional_labels = {label for o in output for label in o.input.labels}
133
141
  # From the way the code is written, we can assert that there is only one collection and one template hash
134
142
  assert len(collections) == 1
135
143
  assert len(collection_hashes) == 1
@@ -158,6 +166,12 @@ class MergeRequestManager(MergeRequestManagerBase[TemplateInfo]):
158
166
  logging.info("Opening MR for %s with hash (%s)", collection, collection_hash)
159
167
  mr_labels = [TR_LABEL]
160
168
 
169
+ if data.auto_approved:
170
+ mr_labels.append(AUTO_MERGE)
171
+
172
+ if additional_labels:
173
+ mr_labels.extend(additional_labels)
174
+
161
175
  self._vcs.open_app_interface_merge_request(
162
176
  mr=TemplateRenderingMR(
163
177
  title=title,
@@ -1,15 +1,16 @@
1
- from typing import Optional
2
-
3
1
  from pydantic import BaseModel
4
2
 
5
3
 
6
4
  class TemplateInput(BaseModel):
7
5
  collection: str
8
6
  collection_hash: str
7
+ enable_auto_approval: bool = False
8
+ labels: list[str] = []
9
9
 
10
10
 
11
11
  class TemplateOutput(BaseModel):
12
- input: Optional[TemplateInput]
12
+ input: TemplateInput
13
13
  is_new: bool = False
14
14
  path: str
15
15
  content: str
16
+ auto_approved: bool = False
@@ -17,6 +17,7 @@ from reconcile.gql_definitions.templating.template_collection import (
17
17
  )
18
18
  from reconcile.templating.lib.merge_request_manager import (
19
19
  MergeRequestManager,
20
+ MrData,
20
21
  create_parser,
21
22
  )
22
23
  from reconcile.templating.lib.model import TemplateInput, TemplateOutput
@@ -59,6 +60,19 @@ class FilePersistence(ABC):
59
60
  def read(self, path: str) -> Optional[str]:
60
61
  pass
61
62
 
63
+ @staticmethod
64
+ def _read_local_file(path: str) -> Optional[str]:
65
+ try:
66
+ with open(
67
+ path,
68
+ "r",
69
+ encoding="utf-8",
70
+ ) as f:
71
+ return f.read()
72
+ except FileNotFoundError:
73
+ logging.debug(f"File not found: {path}, need to create it")
74
+ return None
75
+
62
76
 
63
77
  class LocalFilePersistence(FilePersistence):
64
78
  """
@@ -80,16 +94,7 @@ class LocalFilePersistence(FilePersistence):
80
94
  f.write(output.content)
81
95
 
82
96
  def read(self, path: str) -> Optional[str]:
83
- try:
84
- with open(
85
- f"{join_path(self.app_interface_data_path, path)}",
86
- "r",
87
- encoding="utf-8",
88
- ) as f:
89
- return f.read()
90
- except FileNotFoundError:
91
- logging.debug(f"File not found: {path}, need to create it")
92
- return None
97
+ return self._read_local_file(join_path(self.app_interface_data_path, path))
93
98
 
94
99
 
95
100
  class PersistenceTransaction(FilePersistence):
@@ -127,6 +132,8 @@ class ClonedRepoGitlabPersistence(FilePersistence):
127
132
  """
128
133
  This class is used to persist the rendered templates in a cloned gitlab repo
129
134
  Reads are from the local filesystem, writes are done via utils.VCS abstraction
135
+
136
+ Only one MR is created per run. Auto-approval MRs are prefered.
130
137
  """
131
138
 
132
139
  def __init__(self, local_path: str, vcs: VCS, mr_manager: MergeRequestManager):
@@ -136,19 +143,19 @@ class ClonedRepoGitlabPersistence(FilePersistence):
136
143
 
137
144
  def write(self, outputs: list[TemplateOutput]) -> None:
138
145
  self.mr_manager.housekeeping()
139
- self.mr_manager.create_merge_request(outputs)
146
+
147
+ if any([o.input.enable_auto_approval for o in outputs]):
148
+ auto_approved = [o for o in outputs if o.auto_approved]
149
+ if auto_approved:
150
+ self.mr_manager.create_merge_request(
151
+ MrData(data=auto_approved, auto_approved=True)
152
+ )
153
+ return
154
+
155
+ self.mr_manager.create_merge_request(MrData(data=outputs, auto_approved=False))
140
156
 
141
157
  def read(self, path: str) -> Optional[str]:
142
- try:
143
- with open(
144
- f"{join_path(self.local_path, path)}",
145
- "r",
146
- encoding="utf-8",
147
- ) as f:
148
- return f.read()
149
- except FileNotFoundError:
150
- logging.debug(f"File not found: {path}, need to create it")
151
- return None
158
+ return self._read_local_file(join_path(self.local_path, path))
152
159
 
153
160
 
154
161
  def unpack_static_variables(
@@ -187,6 +194,7 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
187
194
  variables: dict,
188
195
  persistence: FilePersistence,
189
196
  ruaml_instance: yaml.YAML,
197
+ template_input: TemplateInput,
190
198
  ) -> Optional[TemplateOutput]:
191
199
  r = create_renderer(
192
200
  template,
@@ -217,6 +225,8 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
217
225
  path=target_path,
218
226
  content=output,
219
227
  is_new=current_str is None,
228
+ auto_approved=template.auto_approved or False,
229
+ input=template_input,
220
230
  )
221
231
  return None
222
232
 
@@ -244,18 +254,17 @@ class TemplateRendererIntegration(QontractReconcileIntegration):
244
254
  ).hexdigest()
245
255
 
246
256
  with PersistenceTransaction(persistence, dry_run) as p:
257
+ input = TemplateInput(
258
+ collection=c.name,
259
+ collection_hash=template_hash,
260
+ enable_auto_approval=c.enable_auto_approval or False,
261
+ labels=c.additional_mr_labels or [],
262
+ )
247
263
  for template in c.templates:
248
264
  output = self.process_template(
249
- template,
250
- variables,
251
- p,
252
- ruamel_instance,
265
+ template, variables, p, ruamel_instance, input
253
266
  )
254
267
  if output:
255
- output.input = TemplateInput(
256
- collection=c.name,
257
- collection_hash=template_hash,
258
- )
259
268
  outputs.append(output)
260
269
 
261
270
  if not dry_run:
@@ -127,7 +127,11 @@ class TemplateValidatorIntegration(QontractReconcileIntegration):
127
127
  if diffs:
128
128
  for diff in diffs:
129
129
  logging.error(f"template: {diff.template}, test: {diff.test}")
130
- logging.debug(diff.diff)
130
+ # This log should never be added except for local debugging.
131
+ # Credentials could be leaked, i.e. creating an MR with a diff,
132
+ # using a template, that uses the vault function.
133
+ # Use template-validator CLI instead.
134
+ # logging.debug(diff.diff)
131
135
  raise ValueError("Template validation failed")
132
136
 
133
137
  @property
@@ -0,0 +1,399 @@
1
+ import copy
2
+ from typing import Any
3
+
4
+ import pytest
5
+ from pytest_mock import MockerFixture
6
+
7
+ from reconcile.acs_notifiers import AcsNotifiersIntegration
8
+ from reconcile.gql_definitions.acs.acs_policies import (
9
+ AcsPolicyIntegrationNotifierJiraV1,
10
+ AcsPolicyIntegrationNotifiersV1,
11
+ AcsPolicyIntegrationsV1,
12
+ AcsPolicyScopeClusterV1,
13
+ AcsPolicyScopeNamespaceV1,
14
+ AcsPolicyV1,
15
+ AppEscalationPolicyChannelsV1,
16
+ AppEscalationPolicyV1,
17
+ DisableJiraBoardAutomationsV1,
18
+ JiraBoardV1,
19
+ JiraServerV1,
20
+ JiraSeverityPriorityMappingsV1,
21
+ SeverityPriorityMappingV1,
22
+ )
23
+ from reconcile.utils.acs.notifiers import (
24
+ AcsNotifiersApi,
25
+ JiraCredentials,
26
+ JiraNotifier,
27
+ SeverityPriorityMapping,
28
+ )
29
+
30
+
31
+ @pytest.fixture
32
+ def severity_priority_mapping() -> SeverityPriorityMapping:
33
+ return SeverityPriorityMapping(severity="critical", priority="Critical")
34
+
35
+
36
+ @pytest.fixture
37
+ def severity_priority_mapping_api_payload() -> dict[str, str]:
38
+ return {
39
+ "severity": "CRITICAL_SEVERITY",
40
+ "priorityName": "Critical",
41
+ }
42
+
43
+
44
+ def test_severity_priority_mappings_to_api(
45
+ severity_priority_mapping: SeverityPriorityMapping,
46
+ severity_priority_mapping_api_payload: dict[str, str],
47
+ ) -> None:
48
+ assert severity_priority_mapping.to_api() == severity_priority_mapping_api_payload
49
+
50
+
51
+ def test_severity_priority_mappings_from_api(
52
+ severity_priority_mapping: SeverityPriorityMapping,
53
+ severity_priority_mapping_api_payload: dict[str, str],
54
+ ) -> None:
55
+ assert (
56
+ SeverityPriorityMapping.from_api(severity_priority_mapping_api_payload)
57
+ == severity_priority_mapping
58
+ )
59
+
60
+
61
+ @pytest.fixture
62
+ def jira_credentials() -> JiraCredentials:
63
+ return JiraCredentials(
64
+ url="https://jira.example.com",
65
+ username="jirabot",
66
+ token="topsecret",
67
+ )
68
+
69
+
70
+ @pytest.fixture
71
+ def jira_notifier(
72
+ severity_priority_mapping: SeverityPriorityMapping,
73
+ jira_credentials: JiraCredentials,
74
+ ) -> JiraNotifier:
75
+ return JiraNotifier(
76
+ name="jira-notifier-1",
77
+ board="JIRAPLAY",
78
+ url=jira_credentials.url,
79
+ issue_type="Task",
80
+ severity_priority_mappings=[severity_priority_mapping],
81
+ custom_fields={"security": {"id": "0"}},
82
+ )
83
+
84
+
85
+ @pytest.fixture
86
+ def jira_notifier_api_payload(
87
+ severity_priority_mapping: SeverityPriorityMapping,
88
+ jira_credentials: JiraCredentials,
89
+ ) -> dict[str, Any]:
90
+ return {
91
+ "name": "jira-notifier-1",
92
+ "type": "jira",
93
+ "uiEndpoint": "https://acs.example.com",
94
+ "labelDefault": "JIRAPLAY",
95
+ "jira": {
96
+ "url": jira_credentials.url,
97
+ "username": jira_credentials.username,
98
+ "password": jira_credentials.token,
99
+ "issueType": "Task",
100
+ "priorityMappings": [severity_priority_mapping.to_api()],
101
+ "defaultFieldsJson": '{"security": {"id": "0"}}',
102
+ },
103
+ }
104
+
105
+
106
+ def test_jira_notifier_to_api(
107
+ jira_notifier: JiraNotifier,
108
+ jira_credentials: JiraCredentials,
109
+ jira_notifier_api_payload: dict[str, Any],
110
+ ) -> None:
111
+ assert (
112
+ jira_notifier.to_api(
113
+ ui_endpoint="https://acs.example.com", jira_credentials=jira_credentials
114
+ )
115
+ == jira_notifier_api_payload
116
+ )
117
+
118
+
119
+ @pytest.fixture
120
+ def acs_notifier_api() -> AcsNotifiersApi:
121
+ return AcsNotifiersApi(
122
+ url="https://acs.example.com",
123
+ token="topsecret",
124
+ )
125
+
126
+
127
+ @pytest.fixture
128
+ def acs_notifier_api_notifiers_api_payload(
129
+ jira_notifier: JiraNotifier,
130
+ jira_credentials: JiraCredentials,
131
+ ) -> list[Any]:
132
+ return [
133
+ jira_notifier.to_api(
134
+ ui_endpoint="https://acs.example.com", jira_credentials=jira_credentials
135
+ )
136
+ ]
137
+
138
+
139
+ def test_acs_notifier_api_get_notifiers(
140
+ mocker: MockerFixture,
141
+ acs_notifier_api_notifiers_api_payload: list[Any],
142
+ acs_notifier_api: AcsNotifiersApi,
143
+ ) -> None:
144
+ generic_request_mock = mocker.patch(
145
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.generic_request_json"
146
+ )
147
+ generic_request_mock.return_value = {
148
+ "notifiers": acs_notifier_api_notifiers_api_payload
149
+ }
150
+
151
+ assert acs_notifier_api.get_notifiers() == acs_notifier_api_notifiers_api_payload
152
+ generic_request_mock.assert_called_once_with("/v1/notifiers", "GET")
153
+
154
+
155
+ def test_acs_notifier_api_get_jira_notifiers(
156
+ mocker: MockerFixture,
157
+ acs_notifier_api_notifiers_api_payload: list[Any],
158
+ acs_notifier_api: AcsNotifiersApi,
159
+ jira_notifier: JiraNotifier,
160
+ ) -> None:
161
+ get_notifiers_mock = mocker.patch(
162
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.get_notifiers"
163
+ )
164
+ get_notifiers_mock.return_value = acs_notifier_api_notifiers_api_payload
165
+
166
+ assert acs_notifier_api.get_jira_notifiers() == [jira_notifier]
167
+
168
+
169
+ def test_get_notifier_id_by_name(
170
+ mocker: MockerFixture,
171
+ acs_notifier_api_notifiers_api_payload: list[Any],
172
+ acs_notifier_api: AcsNotifiersApi,
173
+ ) -> None:
174
+ get_notifiers_mock = mocker.patch(
175
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.get_notifiers"
176
+ )
177
+ get_notifiers_mock.return_value = acs_notifier_api_notifiers_api_payload
178
+ acs_notifier_api_notifiers_api_payload[0]["id"] = "jira-notifier-id-1"
179
+
180
+ assert (
181
+ acs_notifier_api.get_notifier_id_by_name("jira-notifier-1")
182
+ == "jira-notifier-id-1"
183
+ )
184
+ get_notifiers_mock.assert_called_once()
185
+
186
+
187
+ def test_update_jira_notifier(
188
+ mocker: MockerFixture,
189
+ acs_notifier_api: AcsNotifiersApi,
190
+ jira_notifier: JiraNotifier,
191
+ jira_credentials: JiraCredentials,
192
+ ) -> None:
193
+ get_notifier_id_by_name_mock = mocker.patch(
194
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.get_notifier_id_by_name"
195
+ )
196
+ get_notifier_id_by_name_mock.return_value = "jira-notifier-id-1"
197
+ generic_request_mock = mocker.patch(
198
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.generic_request"
199
+ )
200
+
201
+ acs_notifier_api.update_jira_notifier(jira_notifier, jira_credentials)
202
+ get_notifier_id_by_name_mock.assert_called_once_with(jira_notifier.name)
203
+ body = jira_notifier.to_api(acs_notifier_api.url, jira_credentials)
204
+ generic_request_mock.assert_called_once_with(
205
+ "/v1/notifiers/jira-notifier-id-1", "PUT", body
206
+ )
207
+
208
+
209
+ def test_create_jira_notifier(
210
+ mocker: MockerFixture,
211
+ acs_notifier_api: AcsNotifiersApi,
212
+ jira_notifier: JiraNotifier,
213
+ jira_credentials: JiraCredentials,
214
+ ) -> None:
215
+ generic_request_mock = mocker.patch(
216
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.generic_request"
217
+ )
218
+ acs_notifier_api.create_jira_notifier(jira_notifier, jira_credentials)
219
+ body = jira_notifier.to_api(acs_notifier_api.url, jira_credentials)
220
+ generic_request_mock.assert_called_once_with("/v1/notifiers", "POST", body)
221
+
222
+
223
+ def test_delete_jira_notifier(
224
+ mocker: MockerFixture,
225
+ acs_notifier_api: AcsNotifiersApi,
226
+ jira_notifier: JiraNotifier,
227
+ ) -> None:
228
+ get_notifier_id_by_name_mock = mocker.patch(
229
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.get_notifier_id_by_name"
230
+ )
231
+ get_notifier_id_by_name_mock.return_value = "jira-notifier-id-1"
232
+ generic_request_mock = mocker.patch(
233
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.generic_request"
234
+ )
235
+ acs_notifier_api.delete_jira_notifier(jira_notifier)
236
+ get_notifier_id_by_name_mock.assert_called_once_with(jira_notifier.name)
237
+ generic_request_mock.assert_called_once_with(
238
+ "/v1/notifiers/jira-notifier-id-1", "DELETE"
239
+ )
240
+
241
+
242
+ @pytest.fixture
243
+ def acs_notifier_integration() -> AcsNotifiersIntegration:
244
+ return AcsNotifiersIntegration()
245
+
246
+
247
+ @pytest.fixture
248
+ def escalation_policy() -> AppEscalationPolicyV1:
249
+ return AppEscalationPolicyV1(
250
+ name="notifier-1",
251
+ channels=AppEscalationPolicyChannelsV1(
252
+ jiraBoard=[
253
+ JiraBoardV1(
254
+ name="JIRAPLAY",
255
+ server=JiraServerV1(
256
+ serverUrl="https://jira.example.com",
257
+ ),
258
+ severityPriorityMappings=JiraSeverityPriorityMappingsV1(
259
+ name="sp",
260
+ mappings=[
261
+ SeverityPriorityMappingV1(
262
+ severity="critical", priority="Critical"
263
+ )
264
+ ],
265
+ ),
266
+ issueType="Task",
267
+ issueSecurityId="0",
268
+ disable=DisableJiraBoardAutomationsV1(integrations=[]),
269
+ )
270
+ ],
271
+ jiraComponent="",
272
+ jiraLabels=[],
273
+ ),
274
+ )
275
+
276
+
277
+ @pytest.fixture
278
+ def acs_policies(
279
+ escalation_policy: AppEscalationPolicyV1,
280
+ ) -> list[AcsPolicyV1]:
281
+ return [
282
+ AcsPolicyV1(
283
+ name="acs-policy-1",
284
+ description="CVEs within app-sre clusters with CVSS score gte to 7 and fixable",
285
+ severity="high",
286
+ notifiers=[],
287
+ integrations=AcsPolicyIntegrationsV1(
288
+ notifiers=AcsPolicyIntegrationNotifiersV1(
289
+ jira=AcsPolicyIntegrationNotifierJiraV1(
290
+ escalationPolicy=escalation_policy,
291
+ ),
292
+ ),
293
+ ),
294
+ categories=["vulnerability-management"],
295
+ scope=AcsPolicyScopeClusterV1(
296
+ level="cluster",
297
+ clusters=[],
298
+ ),
299
+ conditions=[],
300
+ ),
301
+ AcsPolicyV1(
302
+ name="acs-policy-2",
303
+ description="image security policy violations of critical severity within app-sre namespaces",
304
+ severity="critical",
305
+ notifiers=[],
306
+ integrations=AcsPolicyIntegrationsV1(
307
+ notifiers=AcsPolicyIntegrationNotifiersV1(
308
+ jira=AcsPolicyIntegrationNotifierJiraV1(
309
+ escalationPolicy=escalation_policy,
310
+ ),
311
+ ),
312
+ ),
313
+ categories=["vulnerability-management", "devops-best-practices"],
314
+ scope=AcsPolicyScopeNamespaceV1(
315
+ level="namespace",
316
+ namespaces=[],
317
+ ),
318
+ conditions=[],
319
+ ),
320
+ ]
321
+
322
+
323
+ def test_integration_get_escalation_policies(
324
+ acs_notifier_integration: AcsNotifiersIntegration,
325
+ acs_policies: list[AcsPolicyV1],
326
+ escalation_policy: AppEscalationPolicyV1,
327
+ ) -> None:
328
+ result = acs_notifier_integration._get_escalation_policies(acs_policies)
329
+
330
+ assert len(acs_policies) > 1
331
+ assert len(result) == 1
332
+ assert result[0] == escalation_policy
333
+
334
+
335
+ def test_build_jira_notifier(
336
+ acs_notifier_integration: AcsNotifiersIntegration,
337
+ escalation_policy: AppEscalationPolicyV1,
338
+ jira_notifier: JiraNotifier,
339
+ ) -> None:
340
+ assert (
341
+ acs_notifier_integration._build_jira_notifier(escalation_policy)
342
+ == jira_notifier
343
+ )
344
+
345
+
346
+ def test_get_desired_state(
347
+ acs_notifier_integration: AcsNotifiersIntegration,
348
+ acs_policies: list[AcsPolicyV1],
349
+ jira_notifier: JiraNotifier,
350
+ ) -> None:
351
+ assert acs_notifier_integration.get_desired_state(acs_policies) == [jira_notifier]
352
+
353
+
354
+ def test_reconcile(
355
+ mocker: MockerFixture,
356
+ acs_notifier_integration: AcsNotifiersIntegration,
357
+ acs_notifier_api: AcsNotifiersApi,
358
+ jira_credentials: JiraCredentials,
359
+ jira_notifier: JiraNotifier,
360
+ ) -> None:
361
+ notifier_to_add = copy.deepcopy(jira_notifier)
362
+ notifier_to_add.name = "jira-notifier-to-add"
363
+ notifier_to_update_current = copy.deepcopy(jira_notifier)
364
+ notifier_to_update_current.name = "jira-notifier-to-update"
365
+ notifier_to_update_current.issue_type = "Task"
366
+ notifier_to_update_desired = copy.deepcopy(jira_notifier)
367
+ notifier_to_update_desired.name = "jira-notifier-to-update"
368
+ notifier_to_update_current.issue_type = "Bug"
369
+ notifier_to_delete = copy.deepcopy(jira_notifier)
370
+ notifier_to_delete.name = "jira-notifier-to-delete"
371
+
372
+ current_state = [notifier_to_delete, notifier_to_update_current]
373
+ desired_state = [notifier_to_add, notifier_to_update_desired]
374
+
375
+ create_jira_notifier_mock = mocker.patch(
376
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.create_jira_notifier"
377
+ )
378
+ update_jira_notifier_mock = mocker.patch(
379
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.update_jira_notifier"
380
+ )
381
+
382
+ delete_jira_notifier_mock = mocker.patch(
383
+ "reconcile.utils.acs.notifiers.AcsNotifiersApi.delete_jira_notifier"
384
+ )
385
+ acs_notifier_integration.reconcile(
386
+ current_state,
387
+ desired_state,
388
+ acs_notifier_api,
389
+ {jira_credentials.url: jira_credentials},
390
+ dry_run=False,
391
+ )
392
+
393
+ create_jira_notifier_mock.assert_called_once_with(
394
+ notifier_to_add, jira_credentials=jira_credentials
395
+ )
396
+ update_jira_notifier_mock.assert_called_once_with(
397
+ notifier_to_update_desired, jira_credentials=jira_credentials
398
+ )
399
+ delete_jira_notifier_mock.assert_called_once_with(notifier_to_delete)
@@ -10,11 +10,20 @@ from reconcile.gql_definitions.acs.acs_policies import (
10
10
  AcsPolicyConditionsCveV1,
11
11
  AcsPolicyConditionsCvssV1,
12
12
  AcsPolicyConditionsSeverityV1,
13
+ AcsPolicyIntegrationNotifierJiraV1,
14
+ AcsPolicyIntegrationNotifiersV1,
15
+ AcsPolicyIntegrationsV1,
13
16
  AcsPolicyQueryData,
14
17
  AcsPolicyScopeClusterV1,
15
18
  AcsPolicyScopeNamespaceV1,
16
19
  AcsPolicyV1,
20
+ AppEscalationPolicyChannelsV1,
21
+ AppEscalationPolicyV1,
17
22
  ClusterV1,
23
+ DisableJiraBoardAutomationsV1,
24
+ JiraBoardV1,
25
+ JiraServerV1,
26
+ JiraSeverityPriorityMappingsV1,
18
27
  NamespaceV1,
19
28
  NamespaceV1_ClusterV1,
20
29
  )
@@ -41,6 +50,36 @@ def query_data_desired_state() -> AcsPolicyQueryData:
41
50
  description="CVEs within app-sre clusters with CVSS score gte to 7 and fixable",
42
51
  severity="high",
43
52
  notifiers=[JIRA_NOTIFIER_NAME],
53
+ integrations=AcsPolicyIntegrationsV1(
54
+ notifiers=AcsPolicyIntegrationNotifiersV1(
55
+ jira=AcsPolicyIntegrationNotifierJiraV1(
56
+ escalationPolicy=AppEscalationPolicyV1(
57
+ name="test-ep",
58
+ channels=AppEscalationPolicyChannelsV1(
59
+ jiraBoard=[
60
+ JiraBoardV1(
61
+ name="board",
62
+ server=JiraServerV1(
63
+ serverUrl="server",
64
+ ),
65
+ severityPriorityMappings=JiraSeverityPriorityMappingsV1(
66
+ name="sp",
67
+ mappings=[],
68
+ ),
69
+ issueType="Task",
70
+ issueSecurityId="0",
71
+ disable=DisableJiraBoardAutomationsV1(
72
+ integrations=[]
73
+ ),
74
+ )
75
+ ],
76
+ jiraComponent="",
77
+ jiraLabels=[],
78
+ ),
79
+ ),
80
+ ),
81
+ ),
82
+ ),
44
83
  categories=["vulnerability-management"],
45
84
  scope=AcsPolicyScopeClusterV1(
46
85
  level="cluster",
@@ -61,6 +100,36 @@ def query_data_desired_state() -> AcsPolicyQueryData:
61
100
  description="image security policy violations of critical severity within app-sre namespaces",
62
101
  severity="critical",
63
102
  notifiers=[],
103
+ integrations=AcsPolicyIntegrationsV1(
104
+ notifiers=AcsPolicyIntegrationNotifiersV1(
105
+ jira=AcsPolicyIntegrationNotifierJiraV1(
106
+ escalationPolicy=AppEscalationPolicyV1(
107
+ name="test-ep",
108
+ channels=AppEscalationPolicyChannelsV1(
109
+ jiraBoard=[
110
+ JiraBoardV1(
111
+ name="board",
112
+ server=JiraServerV1(
113
+ serverUrl="server",
114
+ ),
115
+ severityPriorityMappings=JiraSeverityPriorityMappingsV1(
116
+ name="sp",
117
+ mappings=[],
118
+ ),
119
+ issueType="Task",
120
+ issueSecurityId="0",
121
+ disable=DisableJiraBoardAutomationsV1(
122
+ integrations=[]
123
+ ),
124
+ )
125
+ ],
126
+ jiraComponent="",
127
+ jiraLabels=[],
128
+ ),
129
+ ),
130
+ ),
131
+ ),
132
+ ),
64
133
  categories=["vulnerability-management", "devops-best-practices"],
65
134
  scope=AcsPolicyScopeNamespaceV1(
66
135
  level="namespace",
@@ -75,3 +75,8 @@ class AcsBaseApi(BaseModel):
75
75
  raise
76
76
 
77
77
  return response
78
+
79
+ def generic_request_json(
80
+ self, path: str, verb: str, json: Optional[Any] = None
81
+ ) -> Any:
82
+ return self.generic_request(path, verb, json=json).json()
@@ -0,0 +1,111 @@
1
+ import json
2
+ from typing import Any, Optional
3
+
4
+ from pydantic import BaseModel
5
+
6
+ from reconcile.utils.acs.base import AcsBaseApi
7
+
8
+
9
+ class JiraCredentials(BaseModel):
10
+ url: str
11
+ username: str
12
+ token: str
13
+
14
+
15
+ class SeverityPriorityMapping(BaseModel):
16
+ severity: str
17
+ priority: str
18
+
19
+ @staticmethod
20
+ def from_api(mapping: dict[str, str]) -> "SeverityPriorityMapping":
21
+ return SeverityPriorityMapping(
22
+ severity=mapping["severity"].replace("_SEVERITY", "").lower(),
23
+ priority=mapping["priorityName"],
24
+ )
25
+
26
+ def to_api(self) -> Any:
27
+ return {
28
+ "severity": f"{self.severity.upper()}_SEVERITY",
29
+ "priorityName": self.priority,
30
+ }
31
+
32
+
33
+ class JiraNotifier(BaseModel):
34
+ name: str
35
+ board: str
36
+ url: str
37
+ issue_type: Optional[str]
38
+ severity_priority_mappings: list[SeverityPriorityMapping]
39
+ custom_fields: Optional[dict[str, Any]]
40
+
41
+ @staticmethod
42
+ def from_api(notifier: dict[str, Any]) -> "JiraNotifier":
43
+ notifier_jira = notifier["jira"]
44
+ return JiraNotifier(
45
+ name=notifier["name"],
46
+ board=notifier["labelDefault"],
47
+ url=notifier_jira["url"],
48
+ issue_type=notifier_jira["issueType"],
49
+ severity_priority_mappings=sorted(
50
+ [
51
+ SeverityPriorityMapping.from_api(mapping)
52
+ for mapping in notifier_jira["priorityMappings"]
53
+ ],
54
+ key=lambda m: m.severity,
55
+ ),
56
+ custom_fields=json.loads(notifier_jira.get("defaultFieldsJson") or "{}"),
57
+ )
58
+
59
+ def to_api(self, ui_endpoint: str, jira_credentials: JiraCredentials) -> Any:
60
+ return {
61
+ "name": self.name,
62
+ "type": "jira",
63
+ "uiEndpoint": ui_endpoint,
64
+ "labelDefault": self.board,
65
+ "jira": {
66
+ "url": jira_credentials.url,
67
+ "username": jira_credentials.username,
68
+ "password": jira_credentials.token,
69
+ "issueType": self.issue_type or "Task",
70
+ "priorityMappings": [
71
+ mapping.to_api() for mapping in self.severity_priority_mappings
72
+ ],
73
+ "defaultFieldsJson": json.dumps(self.custom_fields or {}),
74
+ },
75
+ }
76
+
77
+
78
+ class AcsNotifiersApi(AcsBaseApi):
79
+ """
80
+ Implements methods to support reconcile operations against the ACS NotifiersService api
81
+ """
82
+
83
+ def get_notifiers(self) -> list[Any]:
84
+ return self.generic_request_json("/v1/notifiers", "GET")["notifiers"]
85
+
86
+ def get_jira_notifiers(self) -> list[JiraNotifier]:
87
+ return [
88
+ JiraNotifier.from_api(notifier)
89
+ for notifier in self.get_notifiers()
90
+ if notifier["type"] == "jira"
91
+ ]
92
+
93
+ def get_notifier_id_by_name(self, name: str) -> str:
94
+ return [n["id"] for n in self.get_notifiers() if n["name"] == name][0]
95
+
96
+ def update_jira_notifier(
97
+ self, jira_notifier: JiraNotifier, jira_credentials: JiraCredentials
98
+ ) -> None:
99
+ notifier_id = self.get_notifier_id_by_name(jira_notifier.name)
100
+ body = jira_notifier.to_api(self.url, jira_credentials)
101
+ self.generic_request(f"/v1/notifiers/{notifier_id}", "PUT", body)
102
+
103
+ def create_jira_notifier(
104
+ self, jira_notifier: JiraNotifier, jira_credentials: JiraCredentials
105
+ ) -> None:
106
+ body = jira_notifier.to_api(self.url, jira_credentials)
107
+ self.generic_request("/v1/notifiers", "POST", body)
108
+
109
+ def delete_jira_notifier(self, jira_notifier: JiraNotifier) -> None:
110
+ notifier_id = self.get_notifier_id_by_name(jira_notifier.name)
111
+ self.generic_request(f"/v1/notifiers/{notifier_id}", "DELETE")