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.
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/RECORD +42 -18
- reconcile/aws_account_manager/__init__.py +0 -0
- reconcile/aws_account_manager/integration.py +342 -0
- reconcile/aws_account_manager/merge_request_manager.py +111 -0
- reconcile/aws_account_manager/reconciler.py +353 -0
- reconcile/aws_account_manager/utils.py +38 -0
- reconcile/aws_saml_idp/integration.py +2 -0
- reconcile/aws_version_sync/integration.py +12 -11
- reconcile/aws_version_sync/merge_request_manager/merge_request_manager.py +39 -112
- reconcile/cli.py +79 -0
- reconcile/gql_definitions/aws_account_manager/__init__.py +0 -0
- reconcile/gql_definitions/aws_account_manager/aws_accounts.py +163 -0
- reconcile/gql_definitions/cost_report/__init__.py +0 -0
- reconcile/gql_definitions/cost_report/app_names.py +68 -0
- reconcile/gql_definitions/cost_report/settings.py +77 -0
- reconcile/gql_definitions/fragments/aws_account_managed.py +49 -0
- reconcile/queries.py +7 -1
- reconcile/templating/lib/merge_request_manager.py +8 -82
- reconcile/templating/renderer.py +2 -2
- reconcile/typed_queries/cost_report/__init__.py +0 -0
- reconcile/typed_queries/cost_report/app_names.py +22 -0
- reconcile/typed_queries/cost_report/settings.py +15 -0
- reconcile/utils/aws_api_typed/api.py +49 -6
- reconcile/utils/aws_api_typed/iam.py +22 -7
- reconcile/utils/aws_api_typed/organization.py +78 -30
- reconcile/utils/aws_api_typed/service_quotas.py +79 -0
- reconcile/utils/aws_api_typed/support.py +79 -0
- reconcile/utils/merge_request_manager/merge_request_manager.py +102 -0
- reconcile/utils/oauth2_backend_application_session.py +102 -0
- reconcile/utils/state.py +42 -38
- tools/cli_commands/cost_report/__init__.py +0 -0
- tools/cli_commands/cost_report/command.py +172 -0
- tools/cli_commands/cost_report/cost_management_api.py +57 -0
- tools/cli_commands/cost_report/model.py +29 -0
- tools/cli_commands/cost_report/response.py +48 -0
- tools/cli_commands/cost_report/view.py +333 -0
- tools/qontract_cli.py +10 -2
- tools/test/test_qontract_cli.py +20 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc696.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/entry_points.txt +0 -0
- {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
|
148
|
-
"""Return an AWS
|
149
|
-
return self._init_sub_api(
|
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
|
158
|
-
"""Return an AWS
|
159
|
-
return self._init_sub_api(
|
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
|
-
|
37
|
-
UserName=user_name
|
38
|
-
|
39
|
-
|
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
|
49
|
-
"""
|
50
|
-
self.client.
|
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
|
-
|
2
|
-
from
|
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
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
if self.name == name
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
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
|
-
|
38
|
-
|
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
|
-
|
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
|
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=
|
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
|
97
|
-
|
98
|
-
|
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=
|
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
|
+
)
|