qontract-reconcile 0.10.1rc696__py3-none-any.whl → 0.10.1rc702__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.
Files changed (42) hide show
  1. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/METADATA +1 -1
  2. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/RECORD +42 -18
  3. reconcile/aws_account_manager/__init__.py +0 -0
  4. reconcile/aws_account_manager/integration.py +342 -0
  5. reconcile/aws_account_manager/merge_request_manager.py +111 -0
  6. reconcile/aws_account_manager/reconciler.py +353 -0
  7. reconcile/aws_account_manager/utils.py +38 -0
  8. reconcile/aws_saml_idp/integration.py +2 -0
  9. reconcile/aws_version_sync/integration.py +12 -11
  10. reconcile/aws_version_sync/merge_request_manager/merge_request_manager.py +39 -112
  11. reconcile/cli.py +79 -0
  12. reconcile/gql_definitions/aws_account_manager/__init__.py +0 -0
  13. reconcile/gql_definitions/aws_account_manager/aws_accounts.py +163 -0
  14. reconcile/gql_definitions/cost_report/__init__.py +0 -0
  15. reconcile/gql_definitions/cost_report/app_names.py +68 -0
  16. reconcile/gql_definitions/cost_report/settings.py +77 -0
  17. reconcile/gql_definitions/fragments/aws_account_managed.py +49 -0
  18. reconcile/queries.py +7 -1
  19. reconcile/templating/lib/merge_request_manager.py +8 -82
  20. reconcile/templating/renderer.py +2 -2
  21. reconcile/typed_queries/cost_report/__init__.py +0 -0
  22. reconcile/typed_queries/cost_report/app_names.py +22 -0
  23. reconcile/typed_queries/cost_report/settings.py +15 -0
  24. reconcile/utils/aws_api_typed/api.py +49 -6
  25. reconcile/utils/aws_api_typed/iam.py +22 -7
  26. reconcile/utils/aws_api_typed/organization.py +78 -30
  27. reconcile/utils/aws_api_typed/service_quotas.py +79 -0
  28. reconcile/utils/aws_api_typed/support.py +79 -0
  29. reconcile/utils/merge_request_manager/merge_request_manager.py +102 -0
  30. reconcile/utils/oauth2_backend_application_session.py +102 -0
  31. reconcile/utils/state.py +42 -38
  32. tools/cli_commands/cost_report/__init__.py +0 -0
  33. tools/cli_commands/cost_report/command.py +172 -0
  34. tools/cli_commands/cost_report/cost_management_api.py +57 -0
  35. tools/cli_commands/cost_report/model.py +29 -0
  36. tools/cli_commands/cost_report/response.py +48 -0
  37. tools/cli_commands/cost_report/view.py +333 -0
  38. tools/qontract_cli.py +10 -2
  39. tools/test/test_qontract_cli.py +20 -0
  40. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/WHEEL +0 -0
  41. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/entry_points.txt +0 -0
  42. {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/top_level.txt +0 -0
@@ -11,10 +11,14 @@ from pydantic import BaseModel
11
11
 
12
12
  import reconcile.utils.aws_api_typed.iam
13
13
  import reconcile.utils.aws_api_typed.organization
14
+ import reconcile.utils.aws_api_typed.service_quotas
14
15
  import reconcile.utils.aws_api_typed.sts
16
+ import reconcile.utils.aws_api_typed.support
15
17
  from reconcile.utils.aws_api_typed.iam import AWSApiIam
16
18
  from reconcile.utils.aws_api_typed.organization import AWSApiOrganizations
19
+ from reconcile.utils.aws_api_typed.service_quotas import AWSApiServiceQuotas
17
20
  from reconcile.utils.aws_api_typed.sts import AWSApiSts
21
+ from reconcile.utils.aws_api_typed.support import AWSApiSupport
18
22
 
19
23
  SubApi = TypeVar("SubApi")
20
24
 
@@ -109,6 +113,29 @@ class AWSTemporaryCredentials(AWSStaticCredentials):
109
113
 
110
114
 
111
115
  class AWSApi:
116
+ """High-level API for AWS services.
117
+
118
+ This class provides a high-level API for AWS services like
119
+
120
+ * IAM
121
+ * Organizations
122
+ * Service Quotas
123
+ * STS
124
+ * Support
125
+
126
+ It also provides a way to assume roles and create temporary sessions.
127
+
128
+ Example:
129
+
130
+ with AWSApi(AWSStaticCredentials(...)) as api:
131
+ with api.assume_role(... role="MyRole") as role_api:
132
+ role_api.iam.create_user(...)
133
+
134
+
135
+ This new fully-tested and fully-typed API will replace the old one in the near future.
136
+ Feel free to implement missing methods and AWS servcies as needed.
137
+ """
138
+
112
139
  def __init__(self, aws_credentials: AWSCredentials) -> None:
113
140
  self.session = aws_credentials.build_session()
114
141
  self._session_clients: list[BaseClient] = []
@@ -134,9 +161,15 @@ class AWSApi:
134
161
  case reconcile.utils.aws_api_typed.organization.AWSApiOrganizations:
135
162
  client = self.session.client("organizations")
136
163
  api = api_cls(client)
164
+ case reconcile.utils.aws_api_typed.service_quotas.AWSApiServiceQuotas:
165
+ client = self.session.client("service-quotas")
166
+ api = api_cls(client)
137
167
  case reconcile.utils.aws_api_typed.sts.AWSApiSts:
138
168
  client = self.session.client("sts")
139
169
  api = api_cls(client)
170
+ case reconcile.utils.aws_api_typed.support.AWSApiSupport:
171
+ client = self.session.client("support")
172
+ api = api_cls(client)
140
173
  case _:
141
174
  raise ValueError(f"Unknown API class: {api_cls}")
142
175
 
@@ -144,9 +177,9 @@ class AWSApi:
144
177
  return api
145
178
 
146
179
  @cached_property
147
- def sts(self) -> AWSApiSts:
148
- """Return an AWS STS Api client."""
149
- return self._init_sub_api(AWSApiSts)
180
+ def iam(self) -> AWSApiIam:
181
+ """Return an AWS IAM Api client."""
182
+ return self._init_sub_api(AWSApiIam)
150
183
 
151
184
  @cached_property
152
185
  def organizations(self) -> AWSApiOrganizations:
@@ -154,9 +187,19 @@ class AWSApi:
154
187
  return self._init_sub_api(AWSApiOrganizations)
155
188
 
156
189
  @cached_property
157
- def iam(self) -> AWSApiIam:
158
- """Return an AWS IAM Api client."""
159
- return self._init_sub_api(AWSApiIam)
190
+ def service_quotas(self) -> AWSApiServiceQuotas:
191
+ """Return an AWS Service Quotas Api client."""
192
+ return self._init_sub_api(AWSApiServiceQuotas)
193
+
194
+ @cached_property
195
+ def sts(self) -> AWSApiSts:
196
+ """Return an AWS STS Api client."""
197
+ return self._init_sub_api(AWSApiSts)
198
+
199
+ @cached_property
200
+ def support(self) -> AWSApiSupport:
201
+ """Return an AWS Support Api client."""
202
+ return self._init_sub_api(AWSApiSupport)
160
203
 
161
204
  def assume_role(self, account_id: str, role: str) -> AWSApi:
162
205
  """Return a new AWSApi with the assumed role."""
@@ -20,6 +20,10 @@ class AWSUser(BaseModel):
20
20
  path: str = Field(..., alias="Path")
21
21
 
22
22
 
23
+ class AWSEntityAlreadyExistsException(Exception):
24
+ """Raised when the user already exists in IAM."""
25
+
26
+
23
27
  class AWSApiIam:
24
28
  def __init__(self, client: IAMClient) -> None:
25
29
  self.client = client
@@ -33,10 +37,11 @@ class AWSApiIam:
33
37
 
34
38
  def create_user(self, user_name: str) -> AWSUser:
35
39
  """Create a new IAM user."""
36
- user = self.client.create_user(
37
- UserName=user_name,
38
- )
39
- return AWSUser(**user["User"])
40
+ try:
41
+ user = self.client.create_user(UserName=user_name)
42
+ return AWSUser(**user["User"])
43
+ except self.client.exceptions.EntityAlreadyExistsException:
44
+ raise AWSEntityAlreadyExistsException(f"User {user_name} already exists")
40
45
 
41
46
  def attach_user_policy(self, user_name: str, policy_arn: str) -> None:
42
47
  """Attach a policy to a user."""
@@ -45,6 +50,16 @@ class AWSApiIam:
45
50
  PolicyArn=policy_arn,
46
51
  )
47
52
 
48
- def create_account_alias(self, account_alias: str) -> None:
49
- """Create an account alias."""
50
- self.client.create_account_alias(AccountAlias=account_alias)
53
+ def get_account_alias(self) -> str:
54
+ """Get the account alias."""
55
+ return self.client.list_account_aliases()["AccountAliases"][0]
56
+
57
+ def set_account_alias(self, account_alias: str) -> None:
58
+ """Set the account alias."""
59
+ try:
60
+ self.client.create_account_alias(AccountAlias=account_alias)
61
+ except self.client.exceptions.EntityAlreadyExistsException:
62
+ if self.get_account_alias() != account_alias:
63
+ raise ValueError(
64
+ "Account alias already exists for another AWS account. Choose another one!"
65
+ )
@@ -1,5 +1,6 @@
1
- from collections.abc import Mapping
2
- from typing import TYPE_CHECKING
1
+ import functools
2
+ from collections.abc import Iterable, Mapping
3
+ from typing import TYPE_CHECKING, Optional
3
4
 
4
5
  from pydantic import BaseModel, Field
5
6
 
@@ -17,38 +18,65 @@ class AwsOrganizationOU(BaseModel):
17
18
  name: str = Field(..., alias="Name")
18
19
  children: list["AwsOrganizationOU"] = []
19
20
 
20
- def find(self, path: str) -> "AwsOrganizationOU":
21
- """Return an organizational unit by its path."""
22
- name, *rest = path.strip("/").split("/")
23
- subs = "/".join(rest)
24
- if self.name == name:
25
- if not rest:
26
- return self
27
- for child in self.children:
28
- try:
29
- return child.find(subs)
30
- except KeyError:
31
- pass
32
- raise KeyError(f"OU not found: {path}")
21
+ def locate(
22
+ self, path: list[str], ignore_case: bool = True
23
+ ) -> Optional["AwsOrganizationOU"]:
24
+ name, *sub = path
25
+ match = self.name.lower() == name.lower() if ignore_case else self.name == name
26
+ if not match:
27
+ return None
28
+ if not sub:
29
+ return self
30
+ return next(
31
+ (
32
+ result
33
+ for child in self.children
34
+ if (result := child.locate(sub, ignore_case=ignore_case))
35
+ ),
36
+ None,
37
+ )
38
+
39
+ def find(self, path: str, ignore_case: bool = True) -> "AwsOrganizationOU":
40
+ node = self.locate(path.strip("/").split("/"), ignore_case=ignore_case)
41
+ if not node:
42
+ raise KeyError(f"OU not found: {path}")
43
+ return node
44
+
45
+ def __hash__(self) -> int:
46
+ return hash(self.id)
33
47
 
34
48
 
35
49
  class AWSAccountStatus(BaseModel):
36
50
  id: str = Field(..., alias="Id")
37
- account_name: str = Field(..., alias="AccountName")
38
- account_id: str = Field(..., alias="AccountId")
51
+ name: str = Field(..., alias="AccountName")
52
+ uid: str | None = Field(alias="AccountId")
39
53
  state: str = Field(..., alias="State")
40
54
  failure_reason: CreateAccountFailureReasonType | None = Field(alias="FailureReason")
41
55
 
42
56
 
57
+ class AWSAccount(BaseModel):
58
+ name: str = Field(..., alias="Name")
59
+ uid: str = Field(..., alias="Id")
60
+ email: str = Field(..., alias="Email")
61
+ state: str = Field(..., alias="Status")
62
+
63
+
43
64
  class AWSAccountCreationException(Exception):
44
- pass
65
+ """Exception raised when account creation failed."""
66
+
67
+
68
+ class AWSAccountNotFoundException(Exception):
69
+ """Exception raised when the account cannot be found in the specified OU."""
45
70
 
46
71
 
47
72
  class AWSApiOrganizations:
48
73
  def __init__(self, client: OrganizationsClient) -> None:
49
74
  self.client = client
75
+ self.get_organizational_units_tree = functools.lru_cache(maxsize=None)(
76
+ self._get_organizational_units_tree
77
+ )
50
78
 
51
- def get_organizational_units_tree(
79
+ def _get_organizational_units_tree(
52
80
  self, root: AwsOrganizationOU | None = None
53
81
  ) -> AwsOrganizationOU:
54
82
  """List all organizational units for a given root recursively."""
@@ -64,18 +92,13 @@ class AWSApiOrganizations:
64
92
  return root
65
93
 
66
94
  def create_account(
67
- self,
68
- email: str,
69
- account_name: str,
70
- tags: Mapping[str, str],
71
- access_to_billing: bool = True,
95
+ self, email: str, name: str, access_to_billing: bool = True
72
96
  ) -> AWSAccountStatus:
73
97
  """Create a new account in the organization."""
74
98
  resp = self.client.create_account(
75
99
  Email=email,
76
- AccountName=account_name,
100
+ AccountName=name,
77
101
  IamUserAccessToBilling="ALLOW" if access_to_billing else "DENY",
78
- Tags=[{"Key": k, "Value": v} for k, v in tags.items()],
79
102
  )
80
103
  status = AWSAccountStatus(**resp["CreateAccountStatus"])
81
104
  if status.state == "FAILED":
@@ -93,12 +116,37 @@ class AWSApiOrganizations:
93
116
  )
94
117
  return AWSAccountStatus(**resp["CreateAccountStatus"])
95
118
 
96
- def move_account(
97
- self, account_id: str, source_parent_id: str, destination_parent_id: str
98
- ) -> None:
119
+ def get_ou(self, uid: str) -> str:
120
+ """Return the organizational unit ID of an account."""
121
+ resp = self.client.list_parents(ChildId=uid)
122
+ for p in resp.get("Parents", []):
123
+ if p["Type"] in {"ORGANIZATIONAL_UNIT", "ROOT"}:
124
+ return p["Id"]
125
+ raise AWSAccountNotFoundException(f"Account {uid} not found!")
126
+
127
+ def move_account(self, uid: str, destination_parent_id: str) -> None:
99
128
  """Move an account to a different organizational unit."""
129
+ source_parent_id = self.get_ou(uid=uid)
130
+ if source_parent_id == destination_parent_id:
131
+ return
100
132
  self.client.move_account(
101
- AccountId=account_id,
133
+ AccountId=uid,
102
134
  SourceParentId=source_parent_id,
103
135
  DestinationParentId=destination_parent_id,
104
136
  )
137
+
138
+ def describe_account(self, uid: str) -> AWSAccount:
139
+ """Return the status of an account."""
140
+ resp = self.client.describe_account(AccountId=uid)
141
+ return AWSAccount(**resp["Account"])
142
+
143
+ def tag_resource(self, resource_id: str, tags: Mapping[str, str]) -> None:
144
+ """Tag a resource."""
145
+ self.client.tag_resource(
146
+ ResourceId=resource_id,
147
+ Tags=[{"Key": k, "Value": v} for k, v in tags.items()],
148
+ )
149
+
150
+ def untag_resource(self, resource_id: str, tag_keys: Iterable[str]) -> None:
151
+ """Untag a resource."""
152
+ self.client.untag_resource(ResourceId=resource_id, TagKeys=list(tag_keys))
@@ -0,0 +1,79 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ if TYPE_CHECKING:
6
+ from mypy_boto3_service_quotas import ServiceQuotasClient
7
+ from mypy_boto3_service_quotas.literals import RequestStatusType
8
+ else:
9
+ ServiceQuotasClient = RequestStatusType = object
10
+
11
+
12
+ class AWSRequestedServiceQuotaChange(BaseModel):
13
+ id: str = Field(..., alias="Id")
14
+ status: RequestStatusType = Field(..., alias="Status")
15
+ service_code: str = Field(..., alias="ServiceCode")
16
+ quota_code: str = Field(..., alias="QuotaCode")
17
+ desired_value: float = Field(..., alias="DesiredValue")
18
+
19
+
20
+ class AWSQuota(BaseModel):
21
+ service_code: str = Field(..., alias="ServiceCode")
22
+ service_name: str = Field(..., alias="ServiceName")
23
+ quota_code: str = Field(..., alias="QuotaCode")
24
+ quota_name: str = Field(..., alias="QuotaName")
25
+ value: float = Field(..., alias="Value")
26
+
27
+ def __str__(self) -> str:
28
+ return f"{self.service_name=} {self.service_code=} {self.quota_name=} {self.quota_code=}: {self.value=}"
29
+
30
+ def __repr__(self) -> str:
31
+ return str(self)
32
+
33
+
34
+ class AWSNoSuchResourceException(Exception):
35
+ """Raised when a resource is not found in a service quotas API call."""
36
+
37
+
38
+ class AWSResourceAlreadyExistsException(Exception):
39
+ """Raised when quota increase request already exists."""
40
+
41
+
42
+ class AWSApiServiceQuotas:
43
+ def __init__(self, client: ServiceQuotasClient) -> None:
44
+ self.client = client
45
+
46
+ def get_requested_service_quota_change(
47
+ self, request_id: str
48
+ ) -> AWSRequestedServiceQuotaChange:
49
+ """Return the requested service quota change."""
50
+ req = self.client.get_requested_service_quota_change(RequestId=request_id)
51
+ return AWSRequestedServiceQuotaChange(**req["RequestedQuota"])
52
+
53
+ def request_service_quota_change(
54
+ self, service_code: str, quota_code: str, desired_value: float
55
+ ) -> AWSRequestedServiceQuotaChange:
56
+ """Request a service quota change."""
57
+ try:
58
+ req = self.client.request_service_quota_increase(
59
+ ServiceCode=service_code,
60
+ QuotaCode=quota_code,
61
+ DesiredValue=desired_value,
62
+ )
63
+ return AWSRequestedServiceQuotaChange(**req["RequestedQuota"])
64
+ except self.client.exceptions.ResourceAlreadyExistsException:
65
+ raise AWSResourceAlreadyExistsException(
66
+ f"Service quota increase request {service_code=}, {quota_code=} already exists."
67
+ )
68
+
69
+ def get_service_quota(self, service_code: str, quota_code: str) -> AWSQuota:
70
+ """Return the current value of the service quota."""
71
+ try:
72
+ quota = self.client.get_service_quota(
73
+ ServiceCode=service_code, QuotaCode=quota_code
74
+ )
75
+ return AWSQuota(**quota["Quota"])
76
+ except self.client.exceptions.NoSuchResourceException:
77
+ raise AWSNoSuchResourceException(
78
+ f"Service quota {service_code=}, {quota_code=} not found."
79
+ )
@@ -0,0 +1,79 @@
1
+ from enum import Enum
2
+ from typing import TYPE_CHECKING, Literal
3
+
4
+ from pydantic import BaseModel, Field
5
+
6
+ if TYPE_CHECKING:
7
+ from mypy_boto3_support import SupportClient
8
+ else:
9
+ SupportClient = object
10
+
11
+
12
+ class AWSCase(BaseModel):
13
+ case_id: str = Field(..., alias="caseId")
14
+ subject: str
15
+ status: str
16
+
17
+
18
+ class SUPPORT_PLAN(Enum):
19
+ BASIC = "basic"
20
+ DEVELOPER = "developer"
21
+ BUSINESS = "business"
22
+ ENTERPRISE = "enterprise"
23
+
24
+
25
+ class AWSApiSupport:
26
+ def __init__(self, client: SupportClient) -> None:
27
+ self.client = client
28
+
29
+ def create_case(
30
+ self,
31
+ subject: str,
32
+ message: str,
33
+ category: str = "other-account-issues",
34
+ service: str = "customer-account",
35
+ issue_type: Literal["customer-service", "technical"] = "customer-service",
36
+ language: Literal["en", "zh", "ja", "ko"] = "en",
37
+ severity: str = "high",
38
+ ) -> str:
39
+ """Create a support case and return the case id."""
40
+ case = self.client.create_case(
41
+ subject=subject,
42
+ communicationBody=message,
43
+ categoryCode=category,
44
+ serviceCode=service,
45
+ issueType=issue_type,
46
+ language=language,
47
+ severityCode=severity,
48
+ )
49
+ return case["caseId"]
50
+
51
+ def describe_case(self, case_id: str) -> AWSCase:
52
+ """Return the status of a support case."""
53
+ case = self.client.describe_cases(caseIdList=[case_id])["cases"][0]
54
+ return AWSCase(**case)
55
+
56
+ def get_support_level(self) -> SUPPORT_PLAN:
57
+ """Return the support level of the account."""
58
+
59
+ try:
60
+ response = self.client.describe_severity_levels(language="en")
61
+ except self.client.exceptions.ClientError as err:
62
+ if err.response["Error"]["Code"] == "SubscriptionRequiredException":
63
+ return SUPPORT_PLAN.BASIC
64
+ raise err
65
+
66
+ severity_levels = {
67
+ level["code"].lower() for level in response["severityLevels"]
68
+ }
69
+ if "critical" in severity_levels:
70
+ return SUPPORT_PLAN.ENTERPRISE
71
+ if "urgent" in severity_levels:
72
+ return SUPPORT_PLAN.BUSINESS
73
+ if "high" in severity_levels:
74
+ return SUPPORT_PLAN.BUSINESS
75
+ if "normal" in severity_levels:
76
+ return SUPPORT_PLAN.DEVELOPER
77
+ if "low" in severity_levels:
78
+ return SUPPORT_PLAN.DEVELOPER
79
+ return SUPPORT_PLAN.BASIC
@@ -0,0 +1,102 @@
1
+ import logging
2
+ from abc import abstractmethod
3
+ from dataclasses import dataclass
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ from gitlab.v4.objects import ProjectMergeRequest
7
+ from pydantic import BaseModel
8
+
9
+ from reconcile.utils.merge_request_manager.parser import (
10
+ Parser,
11
+ ParserError,
12
+ ParserVersionError,
13
+ )
14
+ from reconcile.utils.vcs import VCS
15
+
16
+ T = TypeVar("T", bound=BaseModel)
17
+
18
+
19
+ @dataclass
20
+ class OpenMergeRequest(Generic[T]):
21
+ raw: ProjectMergeRequest
22
+ mr_info: T
23
+
24
+
25
+ class MergeRequestManagerBase(Generic[T]):
26
+ """ """
27
+
28
+ def __init__(self, vcs: VCS, parser: Parser, mr_label: str):
29
+ self._vcs = vcs
30
+ self._parser = parser
31
+ self._mr_label = mr_label
32
+ self._open_mrs: list[OpenMergeRequest] = []
33
+ self._open_mrs_with_problems: list[OpenMergeRequest] = []
34
+ self._housekeeping_ran = False
35
+
36
+ @abstractmethod
37
+ def create_merge_request(self, data: Any) -> None:
38
+ pass
39
+
40
+ def _merge_request_already_exists(
41
+ self,
42
+ expected_data: dict[str, Any],
43
+ ) -> OpenMergeRequest | None:
44
+ for mr in self._open_mrs:
45
+ mr_info_dict = mr.mr_info.dict()
46
+ if all(
47
+ mr_info_dict.get(k) == expected_data.get(k)
48
+ for k in expected_data.keys()
49
+ ):
50
+ return mr
51
+
52
+ return None
53
+
54
+ def _fetch_managed_open_merge_requests(self) -> list[ProjectMergeRequest]:
55
+ all_open_mrs = self._vcs.get_open_app_interface_merge_requests()
56
+ return [mr for mr in all_open_mrs if self._mr_label in mr.labels]
57
+
58
+ def housekeeping(self) -> None:
59
+ """
60
+ Close bad MRs:
61
+ - bad description format
62
+ - wrong version
63
+ - merge conflict
64
+
65
+ --> if we update the template output, we automatically close
66
+ old open MRs and replace them with new ones.
67
+ """
68
+ for mr in self._fetch_managed_open_merge_requests():
69
+ attrs = mr.attributes
70
+ desc = attrs.get("description")
71
+ has_conflicts = attrs.get("has_conflicts", False)
72
+ if has_conflicts:
73
+ logging.info(
74
+ "Merge-conflict detected. Closing %s",
75
+ mr.attributes.get("web_url", "NO_WEBURL"),
76
+ )
77
+ self._vcs.close_app_interface_mr(
78
+ mr, "Closing this MR because of a merge-conflict."
79
+ )
80
+ continue
81
+ try:
82
+ mr_info = self._parser.parse(description=desc)
83
+ except ParserVersionError:
84
+ logging.info(
85
+ "Old MR version detected! Closing %s",
86
+ mr.attributes.get("web_url", "NO_WEBURL"),
87
+ )
88
+ self._vcs.close_app_interface_mr(
89
+ mr, "Closing this MR because it has an outdated integration version"
90
+ )
91
+ continue
92
+ except ParserError:
93
+ logging.info(
94
+ "Bad MR description format. Closing %s",
95
+ mr.attributes.get("web_url", "NO_WEBURL"),
96
+ )
97
+ self._vcs.close_app_interface_mr(
98
+ mr, "Closing this MR because of bad description format."
99
+ )
100
+ continue
101
+ self._open_mrs.append(OpenMergeRequest(raw=mr, mr_info=mr_info))
102
+ self._housekeeping_ran = True
@@ -0,0 +1,102 @@
1
+ import threading
2
+ from collections.abc import Mapping
3
+ from typing import Any, Self
4
+
5
+ from oauthlib.oauth2 import BackendApplicationClient, TokenExpiredError
6
+ from requests import Response
7
+ from requests_oauthlib import OAuth2Session
8
+
9
+ FETCH_TOKEN_HEADERS = {
10
+ "Accept": "application/json",
11
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
12
+ "Connection": "close", # Close connection to avoid RemoteDisconnected ConnectionError
13
+ }
14
+
15
+
16
+ class OAuth2BackendApplicationSession:
17
+ """
18
+ OAuth2 session using Backend Application flow with auto and thread-safe token fetch.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ client_id: str,
24
+ client_secret: str,
25
+ token_url: str,
26
+ scope: list[str] | None = None,
27
+ ) -> None:
28
+ self.client_id = client_id
29
+ self.client_secret = client_secret
30
+ self.token_url = token_url
31
+ self.scope = scope
32
+ client = BackendApplicationClient(client_id=client_id)
33
+ self.session = OAuth2Session(client=client, scope=scope)
34
+ self.token_lock = threading.Lock()
35
+
36
+ def __enter__(self) -> Self:
37
+ return self
38
+
39
+ def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
40
+ self.close()
41
+
42
+ def close(self) -> None:
43
+ self.session.close()
44
+
45
+ def fetch_token(self) -> dict:
46
+ """
47
+ Fetch token from token_url and store it in the session.
48
+ Thread-safe method to avoid multiple threads fetching the token at the same time.
49
+ """
50
+ token = self.session.token
51
+ with self.token_lock:
52
+ # Check if token is already fetched by another thread
53
+ if token is not self.session.token:
54
+ return self.session.token
55
+ return self.session.fetch_token(
56
+ token_url=self.token_url,
57
+ client_id=self.client_id,
58
+ client_secret=self.client_secret,
59
+ scope=self.scope,
60
+ headers=FETCH_TOKEN_HEADERS,
61
+ )
62
+
63
+ def request(
64
+ self,
65
+ method: str,
66
+ url: str,
67
+ data: Any = None,
68
+ headers: Mapping[str, str] | None = None,
69
+ withhold_token: bool = False,
70
+ client_id: str | None = None,
71
+ client_secret: str | None = None,
72
+ **kwargs: Any,
73
+ ) -> Response:
74
+ """
75
+ Make a request using OAuth2 session, compatible with the OAuth2Session.request method.
76
+ Auto fetch token if never fetched before or if the token is expired.
77
+ """
78
+ if not self.session.authorized:
79
+ self.fetch_token()
80
+ try:
81
+ return self.session.request(
82
+ method=method,
83
+ url=url,
84
+ data=data,
85
+ headers=headers,
86
+ withhold_token=withhold_token,
87
+ client_id=client_id,
88
+ client_secret=client_secret,
89
+ **kwargs,
90
+ )
91
+ except TokenExpiredError:
92
+ self.fetch_token()
93
+ return self.session.request(
94
+ method=method,
95
+ url=url,
96
+ data=data,
97
+ headers=headers,
98
+ withhold_token=withhold_token,
99
+ client_id=client_id,
100
+ client_secret=client_secret,
101
+ **kwargs,
102
+ )