qontract-reconcile 0.10.1rc466__py3-none-any.whl → 0.10.1rc467__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.1rc466
3
+ Version: 0.10.1rc467
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
@@ -8,7 +8,7 @@ reconcile/aws_iam_password_reset.py,sha256=NwErtrqgBiXr7eGCAHdtGGOx0S7-4JnSc29Ie
8
8
  reconcile/aws_support_cases_sos.py,sha256=Jk6_XjDeJSYxgRGqcEAOcynt9qJF2r5HPIPcSKmoBv8,2974
9
9
  reconcile/blackbox_exporter_endpoint_monitoring.py,sha256=W_VJagnsJR1v5oqjlI3RJJE0_nhtJ0m81RS8zWA5u5c,3538
10
10
  reconcile/checkpoint.py,sha256=R2WFXUXLTB4sWMi4GeA4eegsuf_1-Q4vH8M0Toh3Ij4,5036
11
- reconcile/cli.py,sha256=b-5nFAGfzid2XhVc31RnU7bnq-Il-QUxt061Vfe7ehQ,81537
11
+ reconcile/cli.py,sha256=2FIP7dyW9lxJwbJFyZou5noGIl0B0gEj9IKLNM0vk-I,81792
12
12
  reconcile/closedbox_endpoint_monitoring_base.py,sha256=SMhkcQqprWvThrIJa3U_3uh5w1h-alleW1QnCJFY4Qw,4909
13
13
  reconcile/cluster_deployment_mapper.py,sha256=2Ah-nu-Mdig0pjuiZl_XLrmVAjYzFjORR3dMlCgkmw0,2352
14
14
  reconcile/dashdotdb_base.py,sha256=a5aPLVxyqPSbjdB0Ty-uliOtxwvEbbEljHJKxdK3-Zk,4813
@@ -44,7 +44,7 @@ reconcile/jenkins_roles.py,sha256=f8ELpZY36UjoaCpR_9LijQuIMuB6a7sVLFf_H1ct9Hc,44
44
44
  reconcile/jenkins_webhooks.py,sha256=j8vhJMWcRhOdc9XzRSm0CPj84jsF3e4Syjm7r1BIsDE,1978
45
45
  reconcile/jenkins_webhooks_cleaner.py,sha256=JsN_NVPfZJwv1JtSzZXDIHUqGiefL-DRffFnDGau9aY,1539
46
46
  reconcile/jenkins_worker_fleets.py,sha256=PMNGOX0krubFjInPiFT0za0KCiWBLEcVDuXdKRd1BrE,5378
47
- reconcile/jira_permissions_validator.py,sha256=Iul5-2_QgQ8joGfP542UQYA0Y5Qm5chvdRzUltCo_yM,1434
47
+ reconcile/jira_permissions_validator.py,sha256=mXMB5t958gwP1yFQJU8Aml8r0QjdbJ6Dx-6-U12qOiA,9890
48
48
  reconcile/jira_watcher.py,sha256=eyOQ92t8TFi6gogfNTO448h_h1CUyr24E0MPHc51R-o,3617
49
49
  reconcile/ldap_users.py,sha256=uEWQ0V41tN9KCZi4ZKPamjrJ6djSpdpvDBo7yJ0e7ZI,3008
50
50
  reconcile/mr_client_gateway.py,sha256=WhjMd-sIXDFCV8-rt8CEjurJ5OYB1pOD0K3o0tZRXQg,1885
@@ -196,6 +196,7 @@ reconcile/gql_definitions/common/clusters_minimal.py,sha256=yZpjS9qWyusCEiWtD8wz
196
196
  reconcile/gql_definitions/common/clusters_with_peering.py,sha256=GJjV0coYt2IAyeV00rEFZWxz6YOW5txt0FzRdLT2T5w,11099
197
197
  reconcile/gql_definitions/common/github_orgs.py,sha256=rZ0pDAA2_9hF9N-ykRZIxPtEmczTSjuA_k3nkp0k1W0,2039
198
198
  reconcile/gql_definitions/common/jira_settings.py,sha256=Fmjxhlhr69kc4jkG_0k17fuYlQVucbNex0jXYu83wbY,1990
199
+ reconcile/gql_definitions/common/jiralert_settings.py,sha256=H96nMg_r2YcOvioj3aIkwqtFrALGSLt7uhbx9jGSUTo,1984
199
200
  reconcile/gql_definitions/common/namespaces.py,sha256=AmE6XSxGVKYUHjmWI8y2scHw1ya9EfTnEkvffzyRKDE,8922
200
201
  reconcile/gql_definitions/common/namespaces_minimal.py,sha256=XVt8LFe-bGYbjN3ysX3b9sFGmLX4snQ_A9ZouQGaaAI,3429
201
202
  reconcile/gql_definitions/common/ocm_environments.py,sha256=mQyZR04tI_-paCo2FxrK0G-Zl8-izKntuo7Z9fIaY0M,1991
@@ -245,7 +246,7 @@ reconcile/gql_definitions/integrations/integrations.py,sha256=R-COVEcr8OWiOjuYTv
245
246
  reconcile/gql_definitions/jenkins_configs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
246
247
  reconcile/gql_definitions/jenkins_configs/jenkins_configs.py,sha256=0nMkH0G-AjQwu53fqHykth6X6jjbHdW2hBp5n7N-r24,2766
247
248
  reconcile/gql_definitions/jira_permissions_validator/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
248
- reconcile/gql_definitions/jira_permissions_validator/jira_boards_for_permissions_validator.py,sha256=Er-zz8g6EZn6CThzopzBMsOKmUd2kAPo9__8WFLr4WA,2256
249
+ reconcile/gql_definitions/jira_permissions_validator/jira_boards_for_permissions_validator.py,sha256=4_Uz4by1AOfLMicEkBHrUt6hyKYHnbQ9naTsrKbq0as,3365
249
250
  reconcile/gql_definitions/jumphosts/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
250
251
  reconcile/gql_definitions/jumphosts/jumphosts.py,sha256=gN595lx7K1XsB2AfxDQ911TBVBbCoxibVeujnsGue_Q,2371
251
252
  reconcile/gql_definitions/ldap_groups/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -386,6 +387,7 @@ reconcile/test/test_gitlab_members.py,sha256=dP_dm-1THba9Vyzcq-EX1tdmBoX2hq8R-MY
386
387
  reconcile/test/test_instrumented_wrappers.py,sha256=CZzhnQH0c4i7-Rxjg7-0dfFMvVPegLHL46z5NHOOCwo,608
387
388
  reconcile/test/test_integrations_manager.py,sha256=l6KwSFT0NS9VSR-b_9z_ZEGXDWH3EMitUEMC_1h8Xkk,38184
388
389
  reconcile/test/test_jenkins_worker_fleets.py,sha256=o1jlT7OBBSgu0M3iI4xMdz_x6SciF7yhNBpLk5gTJfg,2361
390
+ reconcile/test/test_jira_permissions_validator.py,sha256=FGLLsRV52PQNsYPaxNv7HWxERMouQ9RmUddh-IWJ3ns,12949
389
391
  reconcile/test/test_jump_host.py,sha256=yczTqvT-hNAf9zBMuFjqka9fQOA31SCNG7D-9K9MRPw,3323
390
392
  reconcile/test/test_ldap_users.py,sha256=8jjzVgoiRRylGad6-TvkugoFGXt3eko--zVVKjmZDn4,3812
391
393
  reconcile/test/test_make.py,sha256=zTdjgq-3idFlec_0qJenk9wWw0QMLvSpJfPsptXmync,677
@@ -479,6 +481,7 @@ reconcile/typed_queries/clusters_with_peering.py,sha256=lIai7SJJD0bqIJbe7virgrbY
479
481
  reconcile/typed_queries/github_orgs.py,sha256=UZhoPl8qvA_tcO7CZlN8GuMKckt3ywd47Suu61rgHsc,258
480
482
  reconcile/typed_queries/gitlab_instances.py,sha256=ZVQHy2W9xIp53f5qYkjKLHLHgOVtQpxTfcmM1C2046g,291
481
483
  reconcile/typed_queries/jira_settings.py,sha256=i0ddx5xxHrM1v-9mtL_6OB-jBFLw7-HS6xenpIDjrkw,570
484
+ reconcile/typed_queries/jiralert_settings.py,sha256=y59S5xvYmuaGxszzfKhVLjbCyDwKiaSIlajocbK5MDE,793
482
485
  reconcile/typed_queries/namespaces.py,sha256=vItPrn7sfcHOix-VvkzQkf54_ljzI_ymyxh5esdBJ5Y,262
483
486
  reconcile/typed_queries/namespaces_minimal.py,sha256=rUtqNQ0ORXXUTQfnpsMURymAJ4gYtE77V-Lb3LiJFEY,278
484
487
  reconcile/typed_queries/pagerduty_instances.py,sha256=QCHqEAakiH6eSob0Pnnn3IBd8Ga0zpEp1Z6Qu3v2uH4,733
@@ -527,7 +530,7 @@ reconcile/utils/imap_client.py,sha256=byFAJATbITJPsGECSbvXBOcCnoeTUpDFiEjzOAxLm_
527
530
  reconcile/utils/instrumented_wrappers.py,sha256=eVwMoa6FCrYxLv3RML3WpZF9qKVfCTjMxphgVXG03OM,1073
528
531
  reconcile/utils/jenkins_api.py,sha256=MyJSB_S3uYf3sXnt9t03-gZNQ7tbdd7Wusv3MoF2fRc,7113
529
532
  reconcile/utils/jinja2_ext.py,sha256=l628RR9r9dAGBWLVegoCbSqnjojeizNGiq9Cstt02nE,1129
530
- reconcile/utils/jira_client.py,sha256=pQw4LKZL5d-Guaj4BMiIVrL6EZsmAMYII1-b8xYZ8Yk,5021
533
+ reconcile/utils/jira_client.py,sha256=CxQYWc90YWFESpvFW61Gw5VQAfG9Qn2M4o1WrAeiqv4,6444
531
534
  reconcile/utils/jjb_client.py,sha256=Pdy0dLCFvD6GPCaC0tZydYgkVJPOxYXIiwWECZaFJBU,14551
532
535
  reconcile/utils/jsonpath.py,sha256=NRpAEijKN4cMDjo7qivNPqpm0__GQQ1TiE0PBEBO45s,5572
533
536
  reconcile/utils/jump_host.py,sha256=AdwmCZYNhRe53VwV2iAsUdVyUdVtSd4REmdThJDkM5w,4973
@@ -646,8 +649,8 @@ tools/test/test_app_interface_metrics_exporter.py,sha256=SX7qL3D1SIRKFo95FoQztvf
646
649
  tools/test/test_qontract_cli.py,sha256=awwTHEc2DWlykuqGIYM0WOBoSL0KRnOraCLk3C7izis,1401
647
650
  tools/test/test_sd_app_sre_alert_report.py,sha256=v363r9zM7__0kR5K6mvJoGFcM9BvE33fWAayrqkpojA,2116
648
651
  tools/test/test_sre_checkpoints.py,sha256=SKqPPTl9ua0RFdSSofnoQX-JZE6dFLO3LRhfQzqtfh8,2607
649
- qontract_reconcile-0.10.1rc466.dist-info/METADATA,sha256=pzQbY1450zfzFNl25IISegSnCHqnJs1N0zmryTmQh2w,2348
650
- qontract_reconcile-0.10.1rc466.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
651
- qontract_reconcile-0.10.1rc466.dist-info/entry_points.txt,sha256=rTjAv28I_CHLM8ID3OPqMI_suoQ9s7tFbim4aYjn9kk,376
652
- qontract_reconcile-0.10.1rc466.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
653
- qontract_reconcile-0.10.1rc466.dist-info/RECORD,,
652
+ qontract_reconcile-0.10.1rc467.dist-info/METADATA,sha256=yhMB3IIg1C8P75RrPC2gBb4Pr3NTpREbOTWw91lKSZY,2348
653
+ qontract_reconcile-0.10.1rc467.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
654
+ qontract_reconcile-0.10.1rc467.dist-info/entry_points.txt,sha256=rTjAv28I_CHLM8ID3OPqMI_suoQ9s7tFbim4aYjn9kk,376
655
+ qontract_reconcile-0.10.1rc467.dist-info/top_level.txt,sha256=l5ISPoXzt0SdR4jVdkfa7RPSKNc8zAHYWAnR-Dw8Ey8,24
656
+ qontract_reconcile-0.10.1rc467.dist-info/RECORD,,
reconcile/cli.py CHANGED
@@ -884,11 +884,18 @@ def jenkins_webhooks_cleaner(ctx):
884
884
 
885
885
 
886
886
  @integration.command(short_help="Validate permissions in Jira.")
887
+ @click.option(
888
+ "--exit-on-permission-errors/--no-exit-on-permission-errors",
889
+ help="Throw and error in case of board permission errors. Useful for PR checks.",
890
+ default=True,
891
+ )
887
892
  @click.pass_context
888
- def jira_permissions_validator(ctx):
893
+ def jira_permissions_validator(ctx, exit_on_permission_errors):
889
894
  import reconcile.jira_permissions_validator
890
895
 
891
- run_integration(reconcile.jira_permissions_validator, ctx.obj)
896
+ run_integration(
897
+ reconcile.jira_permissions_validator, ctx.obj, exit_on_permission_errors
898
+ )
892
899
 
893
900
 
894
901
  @integration.command(short_help="Watch for changes in Jira boards and notify on Slack.")
@@ -0,0 +1,68 @@
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 JiralertSettings {
23
+ settings: app_interface_settings_v1 {
24
+ jiralert {
25
+ defaultIssueType
26
+ defaultReopenState
27
+ }
28
+ }
29
+ }
30
+ """
31
+
32
+
33
+ class ConfiguredBaseModel(BaseModel):
34
+ class Config:
35
+ smart_union=True
36
+ extra=Extra.forbid
37
+
38
+
39
+ class JiralertSettingsV1(ConfiguredBaseModel):
40
+ default_issue_type: str = Field(..., alias="defaultIssueType")
41
+ default_reopen_state: str = Field(..., alias="defaultReopenState")
42
+
43
+
44
+ class AppInterfaceSettingsV1(ConfiguredBaseModel):
45
+ jiralert: Optional[JiralertSettingsV1] = Field(..., alias="jiralert")
46
+
47
+
48
+ class JiralertSettingsQueryData(ConfiguredBaseModel):
49
+ settings: Optional[list[AppInterfaceSettingsV1]] = Field(..., alias="settings")
50
+
51
+
52
+ def query(query_func: Callable, **kwargs: Any) -> JiralertSettingsQueryData:
53
+ """
54
+ This is a convenience function which queries and parses the data into
55
+ concrete types. It should be compatible with most GQL clients.
56
+ You do not have to use it to consume the generated data classes.
57
+ Alternatively, you can also mime and alternate the behavior
58
+ of this function in the caller.
59
+
60
+ Parameters:
61
+ query_func (Callable): Function which queries your GQL Server
62
+ kwargs: optional arguments that will be passed to the query function
63
+
64
+ Returns:
65
+ JiralertSettingsQueryData: queried data parsed into generated classes
66
+ """
67
+ raw_data: dict[Any, Any] = query_func(DEFINITION, **kwargs)
68
+ return JiralertSettingsQueryData(**raw_data)
@@ -38,6 +38,19 @@ query JiraBoardsForPermissionValidation {
38
38
  ... VaultSecret
39
39
  }
40
40
  }
41
+ issueType
42
+ issueResolveState
43
+ issueReopenState
44
+ issueSecurityId
45
+ severityPriorityMappings {
46
+ name
47
+ mappings {
48
+ priority
49
+ }
50
+ }
51
+ disable {
52
+ integrations
53
+ }
41
54
  }
42
55
  }
43
56
  """
@@ -54,10 +67,29 @@ class JiraServerV1(ConfiguredBaseModel):
54
67
  token: VaultSecret = Field(..., alias="token")
55
68
 
56
69
 
70
+ class SeverityPriorityMappingV1(ConfiguredBaseModel):
71
+ priority: str = Field(..., alias="priority")
72
+
73
+
74
+ class JiraSeverityPriorityMappingsV1(ConfiguredBaseModel):
75
+ name: str = Field(..., alias="name")
76
+ mappings: list[SeverityPriorityMappingV1] = Field(..., alias="mappings")
77
+
78
+
79
+ class DisableJiraBoardAutomationsV1(ConfiguredBaseModel):
80
+ integrations: Optional[list[str]] = Field(..., alias="integrations")
81
+
82
+
57
83
  class JiraBoardV1(ConfiguredBaseModel):
58
84
  path: str = Field(..., alias="path")
59
85
  name: str = Field(..., alias="name")
60
86
  server: JiraServerV1 = Field(..., alias="server")
87
+ issue_type: Optional[str] = Field(..., alias="issueType")
88
+ issue_resolve_state: Optional[str] = Field(..., alias="issueResolveState")
89
+ issue_reopen_state: Optional[str] = Field(..., alias="issueReopenState")
90
+ issue_security_id: Optional[str] = Field(..., alias="issueSecurityId")
91
+ severity_priority_mappings: JiraSeverityPriorityMappingsV1 = Field(..., alias="severityPriorityMappings")
92
+ disable: Optional[DisableJiraBoardAutomationsV1] = Field(..., alias="disable")
61
93
 
62
94
 
63
95
  class JiraBoardsForPermissionValidationQueryData(ConfiguredBaseModel):
@@ -1,6 +1,18 @@
1
1
  import logging
2
2
  import sys
3
+ from collections.abc import Callable, Iterable
4
+ from enum import IntFlag, auto
5
+ from typing import Any
3
6
 
7
+ from jira import JIRAError
8
+ from pydantic import BaseModel
9
+
10
+ from reconcile.gql_definitions.jira_permissions_validator.jira_boards_for_permissions_validator import (
11
+ DEFINITION as JIRA_BOARDS_DEFINITION,
12
+ )
13
+ from reconcile.gql_definitions.jira_permissions_validator.jira_boards_for_permissions_validator import (
14
+ JiraBoardV1,
15
+ )
4
16
  from reconcile.gql_definitions.jira_permissions_validator.jira_boards_for_permissions_validator import (
5
17
  query as query_jira_boards,
6
18
  )
@@ -9,31 +21,231 @@ from reconcile.typed_queries.app_interface_vault_settings import (
9
21
  get_app_interface_vault_settings,
10
22
  )
11
23
  from reconcile.typed_queries.jira_settings import get_jira_settings
12
- from reconcile.utils import gql
13
- from reconcile.utils.jira_client import JiraClient
14
- from reconcile.utils.secret_reader import create_secret_reader
24
+ from reconcile.typed_queries.jiralert_settings import get_jiralert_settings
25
+ from reconcile.utils import gql, metrics
26
+ from reconcile.utils.disabled_integrations import integration_is_enabled
27
+ from reconcile.utils.jira_client import JiraClient, JiraWatcherSettings
28
+ from reconcile.utils.secret_reader import SecretReaderBase, create_secret_reader
15
29
 
16
30
  QONTRACT_INTEGRATION = "jira-permissions-validator"
17
31
 
32
+ NameToIdMap = dict[str, str]
33
+
34
+
35
+ class BaseMetric(BaseModel):
36
+ """Base class for metrics"""
37
+
38
+ jira_server: str
39
+ board: str
40
+
41
+
42
+ class PermissionErrorCounter(BaseMetric, metrics.GaugeMetric):
43
+ """Boards with permission errors."""
44
+
45
+ @classmethod
46
+ def name(cls) -> str:
47
+ return "jira_permissions_validator_permission_error"
18
48
 
19
- def run(dry_run: bool) -> None:
49
+
50
+ class ValidationError(IntFlag):
51
+ CANT_CREATE_ISSUE = auto()
52
+ CANT_TRANSITION_ISSUES = auto()
53
+ INVALID_ISSUE_TYPE = auto()
54
+ INVALID_ISSUE_STATE = auto()
55
+ INVALID_SECURITY_LEVEL = auto()
56
+ INVALID_PRIORITY = auto()
57
+ PERMISSION_ERROR = auto()
58
+
59
+
60
+ def board_is_valid(
61
+ jira: JiraClient,
62
+ board: JiraBoardV1,
63
+ default_issue_type: str,
64
+ default_reopen_state: str,
65
+ jira_server_priorities: NameToIdMap,
66
+ ) -> ValidationError:
67
+ error = ValidationError(0)
68
+ try:
69
+ if not jira.can_create_issues():
70
+ logging.error(f"[{board.name}] can not create issues in project")
71
+ error |= ValidationError.CANT_CREATE_ISSUE
72
+
73
+ if not jira.can_transition_issues():
74
+ logging.error(
75
+ f"[{board.name}] AppSRE Jira Bot user does not have the permission to change the issue status."
76
+ )
77
+ error |= ValidationError.CANT_TRANSITION_ISSUES
78
+
79
+ issue_type = board.issue_type if board.issue_type else default_issue_type
80
+ project_issue_types = jira.project_issue_types(board.name)
81
+ project_issue_types_str = [i.name for i in project_issue_types]
82
+ if issue_type not in project_issue_types_str:
83
+ logging.error(
84
+ f"[{board.name}] {issue_type} is not a valid issue type in project. Valid issue types: {project_issue_types_str}"
85
+ )
86
+ error |= ValidationError.INVALID_ISSUE_TYPE
87
+
88
+ available_states = []
89
+ for project_issue_type in project_issue_types:
90
+ if issue_type == project_issue_type.name:
91
+ available_states = project_issue_type.statuses
92
+ break
93
+
94
+ if not available_states:
95
+ logging.error(
96
+ f"[{board.name}] {issue_type} doesn't have any status. Choose a different issue type."
97
+ )
98
+ error |= ValidationError.INVALID_ISSUE_TYPE
99
+
100
+ reopen_state = (
101
+ board.issue_reopen_state
102
+ if board.issue_reopen_state
103
+ else default_reopen_state
104
+ )
105
+ if reopen_state.lower() not in [t.lower() for t in available_states]:
106
+ logging.error(
107
+ f"[{board.name}] '{reopen_state}' is not a valid state in project. Valid states: {available_states}"
108
+ )
109
+ error |= ValidationError.INVALID_ISSUE_STATE
110
+
111
+ if board.issue_resolve_state and board.issue_resolve_state.lower() not in [
112
+ t.lower() for t in available_states
113
+ ]:
114
+ logging.error(
115
+ f"[{board.name}] '{board.issue_resolve_state}' is not a valid state in project. Valid states: {available_states}"
116
+ )
117
+ error |= ValidationError.INVALID_ISSUE_STATE
118
+
119
+ if board.issue_security_id:
120
+ security_levels = jira.security_levels()
121
+ if board.issue_security_id not in [level.id for level in security_levels]:
122
+ logging.error(
123
+ f"[{board.name}] {board.issue_security_id} is not a valid security level in project. Valid security ids: "
124
+ + ", ".join([
125
+ f"{level.name} - {level.id}" for level in jira.security_levels()
126
+ ])
127
+ )
128
+ error |= ValidationError.INVALID_SECURITY_LEVEL
129
+
130
+ project_priorities = jira.project_priority_scheme()
131
+ for priority in board.severity_priority_mappings.mappings:
132
+ if priority.priority not in jira_server_priorities:
133
+ logging.error(
134
+ f"[{board.name}] {priority.priority} is not a valid Jira priority. Valid priorities: {project_priorities}"
135
+ )
136
+ error |= ValidationError.INVALID_PRIORITY
137
+ continue
138
+ if jira_server_priorities[priority.priority] not in project_priorities:
139
+ logging.error(
140
+ f"[{board.name}] {priority.priority} is not a valid priority in project. Valid priorities: {project_priorities}"
141
+ )
142
+ error |= ValidationError.INVALID_PRIORITY
143
+ except JIRAError as e:
144
+ if e.status_code != 403:
145
+ raise
146
+ logging.error(
147
+ f"[{board.name}] AppSRE Jira Bot user does not have all necessary permissions. Try granting the user the administrator permissions. API URL: {e.url}"
148
+ )
149
+ error |= ValidationError.PERMISSION_ERROR
150
+
151
+ return error
152
+
153
+
154
+ def validate_boards(
155
+ metrics_container: metrics.MetricsContainer,
156
+ secret_reader: SecretReaderBase,
157
+ exit_on_permission_errors: bool,
158
+ jira_client_settings: JiraWatcherSettings | None,
159
+ jira_boards: Iterable[JiraBoardV1],
160
+ default_issue_type: str,
161
+ default_reopen_state: str,
162
+ jira_client_class: type[JiraClient] = JiraClient,
163
+ ) -> bool:
164
+ error = False
165
+ jira_clients: dict[str, JiraClient] = {}
166
+ for board in jira_boards:
167
+ logging.debug(f"[{board.name}] checking ...")
168
+ if board.server.server_url not in jira_clients:
169
+ jira_clients[board.server.server_url] = jira_client_class.create(
170
+ project_name=board.name,
171
+ token=secret_reader.read_secret(board.server.token),
172
+ server_url=board.server.server_url,
173
+ jira_watcher_settings=jira_client_settings,
174
+ )
175
+
176
+ jira = jira_clients[board.server.server_url]
177
+ jira.project = board.name
178
+ try:
179
+ error_flags = board_is_valid(
180
+ jira=jira,
181
+ board=board,
182
+ default_issue_type=default_issue_type,
183
+ default_reopen_state=default_reopen_state,
184
+ jira_server_priorities={p.name: p.id for p in jira.priorities()},
185
+ )
186
+ match error_flags:
187
+ case 0:
188
+ # no errors
189
+ logging.debug(f"[{board.name}] is valid")
190
+ case ValidationError.PERMISSION_ERROR:
191
+ # we don't have all the permissions, but we can create jira tickets
192
+ metrics_container.set_gauge(
193
+ PermissionErrorCounter(
194
+ jira_server=board.server.server_url,
195
+ board=board.name,
196
+ ),
197
+ value=1,
198
+ )
199
+ # don't fail during PR checks at the moment
200
+ # this make the transistion to the new integration behaviour much smoother
201
+ if exit_on_permission_errors:
202
+ error = True
203
+ case (
204
+ ValidationError.PERMISSION_ERROR
205
+ | ValidationError.CANT_CREATE_ISSUE
206
+ ):
207
+ # we can't create jira tickets, and we don't have all needed the permissions
208
+ error = True
209
+ case _:
210
+ error = True
211
+ except Exception as e:
212
+ logging.error(f"[{board.name}] {e}")
213
+ error = True
214
+ return error
215
+
216
+
217
+ def get_jira_boards(query_func: Callable) -> list[JiraBoardV1]:
218
+ return [
219
+ board
220
+ for board in query_jira_boards(query_func=query_func).jira_boards or []
221
+ if integration_is_enabled(QONTRACT_INTEGRATION, board)
222
+ ]
223
+
224
+
225
+ def run(dry_run: bool, exit_on_permission_errors: bool) -> None:
20
226
  gql_api = gql.get_api()
21
- settings = get_jira_settings(gql_api=gql_api)
227
+ settings = get_jira_settings(gql_api=gql_api.query)
228
+ jiralert_settings = get_jiralert_settings(query_func=gql_api.query)
22
229
  vault_settings = get_app_interface_vault_settings()
23
230
  secret_reader = create_secret_reader(use_vault=vault_settings.vault)
24
- jira_boards = query_jira_boards(query_func=gql_api.query).jira_boards or []
25
- error = False
26
- for jira_board in jira_boards:
27
- token = secret_reader.read_secret(jira_board.server.token)
28
- jira = JiraClient.create(
29
- project_name=jira_board.name,
30
- token=token,
31
- server_url=jira_board.server.server_url,
32
- jira_watcher_settings=settings.jira_watcher,
231
+ boards = get_jira_boards(query_func=gql_api.query)
232
+
233
+ with metrics.transactional_metrics("jira-boards") as metrics_container:
234
+ error = validate_boards(
235
+ metrics_container=metrics_container,
236
+ secret_reader=secret_reader,
237
+ exit_on_permission_errors=exit_on_permission_errors,
238
+ jira_client_settings=settings.jira_watcher,
239
+ jira_boards=boards,
240
+ default_issue_type=jiralert_settings.default_issue_type,
241
+ default_reopen_state=jiralert_settings.default_reopen_state,
33
242
  )
34
- if not jira.can_create_issues():
35
- error = True
36
- logging.error(f"can not create issues in project {jira.project}")
37
243
 
38
244
  if error:
39
245
  sys.exit(ExitCodes.ERROR)
246
+
247
+
248
+ def early_exit_desired_state(*args: Any, **kwargs: Any) -> dict[str, Any]:
249
+ return {
250
+ "boards": gql.get_api().query(JIRA_BOARDS_DEFINITION)["jira_boards"],
251
+ }
@@ -0,0 +1,386 @@
1
+ from collections.abc import Callable, Mapping
2
+ from typing import Any
3
+ from unittest.mock import Mock
4
+
5
+ import pytest
6
+ from jira import JIRAError
7
+ from pytest_mock import MockerFixture
8
+
9
+ from reconcile.gql_definitions.jira_permissions_validator.jira_boards_for_permissions_validator import (
10
+ JiraBoardV1,
11
+ )
12
+ from reconcile.jira_permissions_validator import (
13
+ ValidationError,
14
+ board_is_valid,
15
+ get_jira_boards,
16
+ validate_boards,
17
+ )
18
+ from reconcile.test.fixtures import Fixtures
19
+ from reconcile.utils import metrics
20
+ from reconcile.utils.jira_client import IssueType, JiraClient, SecurityLevel
21
+
22
+
23
+ @pytest.fixture
24
+ def fx() -> Fixtures:
25
+ return Fixtures("jira_permissions_validator")
26
+
27
+
28
+ @pytest.fixture
29
+ def raw_fixture_data(fx: Fixtures) -> dict[str, Any]:
30
+ return fx.get_anymarkup("boards.yml")
31
+
32
+
33
+ @pytest.fixture
34
+ def query_func(
35
+ data_factory: Callable[[type[JiraBoardV1], Mapping[str, Any]], Mapping[str, Any]],
36
+ raw_fixture_data: dict[str, Any],
37
+ ) -> Callable:
38
+ return lambda *args, **kwargs: {
39
+ "jira_boards": [
40
+ data_factory(JiraBoardV1, item) for item in raw_fixture_data["jira_boards"]
41
+ ]
42
+ }
43
+
44
+
45
+ @pytest.fixture
46
+ def boards(query_func: Callable) -> list[JiraBoardV1]:
47
+ return get_jira_boards(query_func)
48
+
49
+
50
+ def test_jira_permissions_validator_get_jira_boards(
51
+ query_func: Callable, gql_class_factory: Callable
52
+ ) -> None:
53
+ default = {
54
+ "name": "jira-board-default",
55
+ "server": {
56
+ "serverUrl": "https://jira-server.com",
57
+ "token": {"path": "vault/path/token", "field": "token"},
58
+ },
59
+ "issueResolveState": "Closed",
60
+ "severityPriorityMappings": {
61
+ "name": "major-major",
62
+ "mappings": [
63
+ {"priority": "Minor"},
64
+ {"priority": "Major"},
65
+ {"priority": "Critical"},
66
+ ],
67
+ },
68
+ }
69
+ custom = {
70
+ "name": "jira-board-custom",
71
+ "server": {
72
+ "serverUrl": "https://jira-server.com",
73
+ "token": {"path": "vault/path/token", "field": "token"},
74
+ },
75
+ "issueType": "bug",
76
+ "issueResolveState": "Closed",
77
+ "issueReopenState": "Open",
78
+ "issueSecurityId": 32168,
79
+ "severityPriorityMappings": {
80
+ "name": "major-major",
81
+ "mappings": [
82
+ {"priority": "Minor"},
83
+ {"priority": "Major"},
84
+ {"priority": "Major"},
85
+ {"priority": "Critical"},
86
+ ],
87
+ },
88
+ }
89
+ assert get_jira_boards(query_func) == [
90
+ gql_class_factory(JiraBoardV1, default),
91
+ gql_class_factory(JiraBoardV1, custom),
92
+ ]
93
+
94
+
95
+ @pytest.mark.parametrize(
96
+ "board_is_valid, exit_on_permission_errors, expected, metric_set",
97
+ [
98
+ (0, True, False, False),
99
+ (ValidationError.CANT_CREATE_ISSUE, True, True, False),
100
+ (ValidationError.CANT_TRANSITION_ISSUES, True, True, False),
101
+ (ValidationError.INVALID_ISSUE_TYPE, True, True, False),
102
+ (ValidationError.INVALID_ISSUE_STATE, True, True, False),
103
+ (ValidationError.INVALID_SECURITY_LEVEL, True, True, False),
104
+ (ValidationError.INVALID_PRIORITY, True, True, False),
105
+ (ValidationError.PERMISSION_ERROR, True, True, True),
106
+ # special case: CANT_CREATE_ISSUE and PERMISSION_ERROR
107
+ (
108
+ ValidationError.CANT_CREATE_ISSUE | ValidationError.PERMISSION_ERROR,
109
+ True,
110
+ True,
111
+ False,
112
+ ),
113
+ (
114
+ ValidationError.CANT_CREATE_ISSUE | ValidationError.PERMISSION_ERROR,
115
+ False,
116
+ True,
117
+ False,
118
+ ),
119
+ # test with another error
120
+ (
121
+ ValidationError.INVALID_PRIORITY | ValidationError.PERMISSION_ERROR,
122
+ True,
123
+ True,
124
+ False,
125
+ ),
126
+ (
127
+ ValidationError.INVALID_PRIORITY | ValidationError.PERMISSION_ERROR,
128
+ False,
129
+ True,
130
+ False,
131
+ ),
132
+ ],
133
+ )
134
+ def test_jira_permissions_validator_validate_boards(
135
+ mocker: MockerFixture,
136
+ boards: list[JiraBoardV1],
137
+ secret_reader: Mock,
138
+ board_is_valid: ValidationError,
139
+ exit_on_permission_errors: bool,
140
+ expected: bool,
141
+ metric_set: bool,
142
+ ) -> None:
143
+ board_is_valid_mock = mocker.patch(
144
+ "reconcile.jira_permissions_validator.board_is_valid"
145
+ )
146
+ board_is_valid_mock.return_value = board_is_valid
147
+ metrics_container_mock = mocker.create_autospec(spec=metrics.MetricsContainer)
148
+ jira_client_class = mocker.create_autospec(spec=JiraClient)
149
+ assert (
150
+ validate_boards(
151
+ metrics_container=metrics_container_mock,
152
+ secret_reader=secret_reader,
153
+ exit_on_permission_errors=exit_on_permission_errors,
154
+ jira_client_settings=None,
155
+ jira_boards=boards,
156
+ default_issue_type="task",
157
+ default_reopen_state="new",
158
+ jira_client_class=jira_client_class,
159
+ )
160
+ == expected
161
+ )
162
+ if metric_set:
163
+ metrics_container_mock.set_gauge.assert_called()
164
+ else:
165
+ metrics_container_mock.set_gauge.assert_not_called()
166
+
167
+
168
+ def test_jira_permissions_validator_board_is_valid_happy_path(
169
+ mocker: MockerFixture, gql_class_factory: Callable
170
+ ) -> None:
171
+ board = gql_class_factory(
172
+ JiraBoardV1,
173
+ {
174
+ "name": "jira-board-default",
175
+ "server": {
176
+ "serverUrl": "https://jira-server.com",
177
+ "token": {"path": "vault/path/token", "field": "token"},
178
+ },
179
+ "issueType": "bug",
180
+ "issueResolveState": "Closed",
181
+ "issueReopenState": "Open",
182
+ "issueSecurityId": "32168",
183
+ "severityPriorityMappings": {
184
+ "name": "major-major",
185
+ "mappings": [
186
+ {"priority": "Minor"},
187
+ {"priority": "Major"},
188
+ {"priority": "Critical"},
189
+ ],
190
+ },
191
+ },
192
+ )
193
+ jira_client = mocker.create_autospec(spec=JiraClient)
194
+ jira_client.can_create_issues.return_value = True
195
+ jira_client.can_transition_issues.return_value = True
196
+ jira_client.project_issue_types.return_value = [
197
+ IssueType(id="1", name="task", statuses=["open", "closed"]),
198
+ IssueType(id="2", name="bug", statuses=["open", "closed"]),
199
+ ]
200
+ jira_client.security_levels.return_value = [
201
+ SecurityLevel(id="32168", name="foo"),
202
+ SecurityLevel(id="1", name="bar"),
203
+ ]
204
+ jira_client.project_priority_scheme.return_value = ["1", "2", "3"]
205
+ assert board_is_valid(
206
+ jira=jira_client,
207
+ board=board,
208
+ default_issue_type="task",
209
+ default_reopen_state="new",
210
+ jira_server_priorities={"Minor": "1", "Major": "2", "Critical": "3"},
211
+ ) == ValidationError(0)
212
+
213
+
214
+ def test_jira_permissions_validator_board_is_valid_all_errors(
215
+ mocker: MockerFixture, gql_class_factory: Callable
216
+ ) -> None:
217
+ board = gql_class_factory(
218
+ JiraBoardV1,
219
+ {
220
+ "name": "jira-board-default",
221
+ "server": {
222
+ "serverUrl": "https://jira-server.com",
223
+ "token": {"path": "vault/path/token", "field": "token"},
224
+ },
225
+ "issueType": "bug",
226
+ "issueResolveState": "Closed",
227
+ "issueReopenState": "Open",
228
+ "issueSecurityId": "32168",
229
+ "severityPriorityMappings": {
230
+ "name": "major-major",
231
+ "mappings": [
232
+ {"priority": "Minor"},
233
+ {"priority": "Major"},
234
+ {"priority": "Critical"},
235
+ ],
236
+ },
237
+ },
238
+ )
239
+ jira_client = mocker.create_autospec(spec=JiraClient)
240
+ jira_client.can_create_issues.return_value = False
241
+ jira_client.can_transition_issues.return_value = False
242
+ jira_client.project_issue_types.return_value = []
243
+ jira_client.security_levels.return_value = [
244
+ SecurityLevel(id="1", name="bar"),
245
+ ]
246
+ jira_client.project_priority_scheme.return_value = ["1", "2"]
247
+ assert (
248
+ board_is_valid(
249
+ jira=jira_client,
250
+ board=board,
251
+ default_issue_type="task",
252
+ default_reopen_state="new",
253
+ jira_server_priorities={"Minor": "1", "Major": "2", "Critical": "3"},
254
+ )
255
+ == ValidationError.CANT_CREATE_ISSUE
256
+ | ValidationError.CANT_TRANSITION_ISSUES
257
+ | ValidationError.INVALID_ISSUE_TYPE
258
+ | ValidationError.INVALID_ISSUE_STATE
259
+ | ValidationError.INVALID_SECURITY_LEVEL
260
+ | ValidationError.INVALID_PRIORITY
261
+ )
262
+
263
+
264
+ def test_jira_permissions_validator_board_is_valid_bad_issue_status(
265
+ mocker: MockerFixture, gql_class_factory: Callable
266
+ ) -> None:
267
+ board = gql_class_factory(
268
+ JiraBoardV1,
269
+ {
270
+ "name": "jira-board-default",
271
+ "server": {
272
+ "serverUrl": "https://jira-server.com",
273
+ "token": {"path": "vault/path/token", "field": "token"},
274
+ },
275
+ "issueType": "bug",
276
+ "issueResolveState": "Closed",
277
+ "issueReopenState": "Open",
278
+ "issueSecurityId": "32168",
279
+ "severityPriorityMappings": {
280
+ "name": "major-major",
281
+ "mappings": [
282
+ {"priority": "Minor"},
283
+ {"priority": "Major"},
284
+ {"priority": "Critical"},
285
+ ],
286
+ },
287
+ },
288
+ )
289
+ jira_client = mocker.create_autospec(spec=JiraClient)
290
+ jira_client.can_create_issues.return_value = True
291
+ jira_client.can_transition_issues.return_value = True
292
+ jira_client.project_issue_types.return_value = [
293
+ IssueType(id="1", name="task", statuses=["not - open", "closed"]),
294
+ IssueType(id="2", name="bug", statuses=["not - open", "closed"]),
295
+ ]
296
+ jira_client.security_levels.return_value = [
297
+ SecurityLevel(id="32168", name="foo"),
298
+ SecurityLevel(id="1", name="bar"),
299
+ ]
300
+ jira_client.project_priority_scheme.return_value = ["1", "2", "3"]
301
+ assert (
302
+ board_is_valid(
303
+ jira=jira_client,
304
+ board=board,
305
+ default_issue_type="task",
306
+ default_reopen_state="new",
307
+ jira_server_priorities={"Minor": "1", "Major": "2", "Critical": "3"},
308
+ )
309
+ == ValidationError.INVALID_ISSUE_STATE
310
+ )
311
+
312
+
313
+ def test_jira_permissions_validator_board_is_valid_permission_error(
314
+ mocker: MockerFixture, gql_class_factory: Callable
315
+ ) -> None:
316
+ board = gql_class_factory(
317
+ JiraBoardV1,
318
+ {
319
+ "name": "jira-board-default",
320
+ "server": {
321
+ "serverUrl": "https://jira-server.com",
322
+ "token": {"path": "vault/path/token", "field": "token"},
323
+ },
324
+ "issueType": "bug",
325
+ "issueResolveState": "Closed",
326
+ "issueReopenState": "Open",
327
+ "issueSecurityId": "32168",
328
+ "severityPriorityMappings": {
329
+ "name": "major-major",
330
+ "mappings": [
331
+ {"priority": "Minor"},
332
+ {"priority": "Major"},
333
+ {"priority": "Critical"},
334
+ ],
335
+ },
336
+ },
337
+ )
338
+ jira_client = mocker.create_autospec(spec=JiraClient)
339
+ jira_client.can_create_issues.side_effect = JIRAError(status_code=403)
340
+ assert (
341
+ board_is_valid(
342
+ jira=jira_client,
343
+ board=board,
344
+ default_issue_type="task",
345
+ default_reopen_state="new",
346
+ jira_server_priorities={"Minor": "1", "Major": "2", "Critical": "3"},
347
+ )
348
+ == ValidationError.PERMISSION_ERROR
349
+ )
350
+
351
+
352
+ def test_jira_permissions_validator_board_is_valid_exception(
353
+ mocker: MockerFixture, gql_class_factory: Callable
354
+ ) -> None:
355
+ board = gql_class_factory(
356
+ JiraBoardV1,
357
+ {
358
+ "name": "jira-board-default",
359
+ "server": {
360
+ "serverUrl": "https://jira-server.com",
361
+ "token": {"path": "vault/path/token", "field": "token"},
362
+ },
363
+ "issueType": "bug",
364
+ "issueResolveState": "Closed",
365
+ "issueReopenState": "Open",
366
+ "issueSecurityId": "32168",
367
+ "severityPriorityMappings": {
368
+ "name": "major-major",
369
+ "mappings": [
370
+ {"priority": "Minor"},
371
+ {"priority": "Major"},
372
+ {"priority": "Critical"},
373
+ ],
374
+ },
375
+ },
376
+ )
377
+ jira_client = mocker.create_autospec(spec=JiraClient)
378
+ jira_client.can_create_issues.side_effect = JIRAError(status_code=401)
379
+ with pytest.raises(JIRAError):
380
+ board_is_valid(
381
+ jira=jira_client,
382
+ board=board,
383
+ default_issue_type="task",
384
+ default_reopen_state="new",
385
+ jira_server_priorities={"Minor": "1", "Major": "2", "Critical": "3"},
386
+ )
@@ -0,0 +1,22 @@
1
+ from collections.abc import Callable
2
+
3
+ from reconcile.gql_definitions.common.jiralert_settings import (
4
+ JiralertSettingsV1,
5
+ query,
6
+ )
7
+ from reconcile.utils import gql
8
+ from reconcile.utils.exceptions import AppInterfaceSettingsError
9
+
10
+
11
+ def get_jiralert_settings(
12
+ query_func: Callable | None = None,
13
+ ) -> JiralertSettingsV1:
14
+ """Returns App Interface Settings and raises err if none are found"""
15
+ if not query_func:
16
+ query_func = gql.get_api().query
17
+ data = query(query_func)
18
+ if data.settings and len(data.settings) == 1:
19
+ if data.settings[0].jiralert:
20
+ return data.settings[0].jiralert
21
+ return JiralertSettingsV1(defaultIssueType="Task", defaultReopenState="To Do")
22
+ raise AppInterfaceSettingsError("jira settings not uniquely defined.")
@@ -16,6 +16,7 @@ from jira import (
16
16
  Issue,
17
17
  )
18
18
  from jira.client import ResultList
19
+ from pydantic import BaseModel
19
20
 
20
21
  from reconcile.utils.secret_reader import SecretReader
21
22
 
@@ -25,6 +26,28 @@ class JiraWatcherSettings(Protocol):
25
26
  connect_timeout: int
26
27
 
27
28
 
29
+ class SecurityLevel(BaseModel):
30
+ """Jira security level."""
31
+
32
+ id: str
33
+ name: str
34
+
35
+
36
+ class Priority(BaseModel):
37
+ """Jira priority."""
38
+
39
+ id: str
40
+ name: str
41
+
42
+
43
+ class IssueType(BaseModel):
44
+ """Jira issue type."""
45
+
46
+ id: str
47
+ name: str
48
+ statuses: list[str]
49
+
50
+
28
51
  class JiraClient:
29
52
  """Wrapper around Jira client."""
30
53
 
@@ -156,3 +179,29 @@ class JiraClient:
156
179
 
157
180
  def can_create_issues(self) -> bool:
158
181
  return self.can_i("CREATE_ISSUES")
182
+
183
+ def can_transition_issues(self) -> bool:
184
+ return self.can_i("TRANSITION_ISSUES")
185
+
186
+ def project_issue_types(self, project: str) -> list[IssueType]:
187
+ return [
188
+ IssueType(id=t.id, name=t.name, statuses=[s.name for s in t.statuses])
189
+ for t in self.jira.issue_types_for_project(project)
190
+ ]
191
+
192
+ def security_levels(self) -> list[SecurityLevel]:
193
+ """Return a list of all available security levels for the project.
194
+
195
+ This API endpoint needs admin/owner project permissions.
196
+ """
197
+ scheme = self.jira.project_issue_security_level_scheme(self.project)
198
+ return [SecurityLevel(id=level.id, name=level.name) for level in scheme.levels]
199
+
200
+ def priorities(self) -> list[Priority]:
201
+ """Return a list of all available Jira priorities."""
202
+ return [Priority(id=p.id, name=p.name) for p in self.jira.priorities()]
203
+
204
+ def project_priority_scheme(self) -> list[str]:
205
+ """Return a list of all priority IDs for the project."""
206
+ scheme = self.jira.project_priority_scheme(self.project)
207
+ return scheme.optionIds