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
@@ -0,0 +1,353 @@
|
|
1
|
+
import logging
|
2
|
+
from collections.abc import Iterable
|
3
|
+
from textwrap import dedent
|
4
|
+
from typing import Any, Protocol
|
5
|
+
|
6
|
+
from reconcile.aws_account_manager.utils import state_key
|
7
|
+
from reconcile.utils.aws_api_typed.api import AWSApi
|
8
|
+
from reconcile.utils.aws_api_typed.iam import (
|
9
|
+
AWSAccessKey,
|
10
|
+
)
|
11
|
+
from reconcile.utils.aws_api_typed.organization import AwsOrganizationOU
|
12
|
+
from reconcile.utils.aws_api_typed.service_quotas import (
|
13
|
+
AWSResourceAlreadyExistsException,
|
14
|
+
)
|
15
|
+
from reconcile.utils.aws_api_typed.support import SUPPORT_PLAN
|
16
|
+
from reconcile.utils.state import AbortStateTransaction, State
|
17
|
+
|
18
|
+
TASK_CREATE_ACCOUNT = "create-account"
|
19
|
+
TASK_DESCRIBE_ACCOUNT = "describe-account"
|
20
|
+
TASK_TAG_ACCOUNT = "tag-account"
|
21
|
+
TASK_MOVE_ACCOUNT = "move-account"
|
22
|
+
TASK_ACCOUNT_ALIAS = "account-alias"
|
23
|
+
TASK_CREATE_IAM_USER = "create-iam-user"
|
24
|
+
TASK_REQUEST_SERVICE_QUOTA = "request-service-quota"
|
25
|
+
TASK_CHECK_SERVICE_QUOTA_STATUS = "check-service-quota-status"
|
26
|
+
TASK_ENABLE_ENTERPRISE_SUPPORT = "enable-enterprise-support"
|
27
|
+
TASK_CHECK_ENTERPRISE_SUPPORT_STATUS = "check-enterprise-support-status"
|
28
|
+
|
29
|
+
|
30
|
+
class Quota(Protocol):
|
31
|
+
service_code: str
|
32
|
+
quota_code: str
|
33
|
+
value: float
|
34
|
+
|
35
|
+
def dict(self) -> dict[str, Any]: ...
|
36
|
+
|
37
|
+
|
38
|
+
class AWSReconciler:
|
39
|
+
def __init__(self, state: State, dry_run: bool) -> None:
|
40
|
+
self.state = state
|
41
|
+
self.dry_run = dry_run
|
42
|
+
|
43
|
+
def _create_account(
|
44
|
+
self,
|
45
|
+
aws_api: AWSApi,
|
46
|
+
name: str,
|
47
|
+
email: str,
|
48
|
+
) -> str | None:
|
49
|
+
"""Create the organization account and return the creation status ID."""
|
50
|
+
with self.state.transaction(state_key(name, TASK_CREATE_ACCOUNT)) as _state:
|
51
|
+
if _state.exists:
|
52
|
+
# account already exists, nothing to do
|
53
|
+
return _state.value
|
54
|
+
|
55
|
+
logging.info(f"Creating account {name}")
|
56
|
+
if self.dry_run:
|
57
|
+
raise AbortStateTransaction("Dry run")
|
58
|
+
|
59
|
+
status = aws_api.organizations.create_account(email=email, name=name)
|
60
|
+
# store the status id for future reference
|
61
|
+
_state.value = status.id
|
62
|
+
return status.id
|
63
|
+
|
64
|
+
def _org_account_exists(
|
65
|
+
self,
|
66
|
+
aws_api: AWSApi,
|
67
|
+
name: str,
|
68
|
+
create_account_request_id: str,
|
69
|
+
) -> str | None:
|
70
|
+
"""Check if the organization account exists and return its ID."""
|
71
|
+
with self.state.transaction(state_key(name, TASK_DESCRIBE_ACCOUNT)) as _state:
|
72
|
+
if _state.exists:
|
73
|
+
# account checked and exists, nothing to do
|
74
|
+
return _state.value
|
75
|
+
|
76
|
+
logging.info(f"Checking account creation status {name}")
|
77
|
+
status = aws_api.organizations.describe_create_account_status(
|
78
|
+
create_account_request_id=create_account_request_id
|
79
|
+
)
|
80
|
+
match status.state:
|
81
|
+
case "SUCCEEDED":
|
82
|
+
_state.value = status.uid
|
83
|
+
return status.uid
|
84
|
+
case "FAILED":
|
85
|
+
raise RuntimeError(
|
86
|
+
f"Account creation failed: {status.failure_reason}"
|
87
|
+
)
|
88
|
+
case "IN_PROGRESS":
|
89
|
+
raise AbortStateTransaction("Account creation still in progress")
|
90
|
+
case _:
|
91
|
+
raise RuntimeError(
|
92
|
+
f"Unexpected account creation status: {status.state}"
|
93
|
+
)
|
94
|
+
|
95
|
+
def _tag_account(
|
96
|
+
self, aws_api: AWSApi, name: str, uid: str, tags: dict[str, str]
|
97
|
+
) -> None:
|
98
|
+
with self.state.transaction(state_key(name, TASK_TAG_ACCOUNT)) as _state:
|
99
|
+
if _state.exists and _state.value == tags:
|
100
|
+
# account already tagged, nothing to do
|
101
|
+
return
|
102
|
+
|
103
|
+
logging.info(f"Tagging account {name}: {tags}")
|
104
|
+
_state.value = tags
|
105
|
+
if self.dry_run:
|
106
|
+
raise AbortStateTransaction("Dry run")
|
107
|
+
|
108
|
+
if _state.exists:
|
109
|
+
aws_api.organizations.untag_resource(
|
110
|
+
resource_id=uid, tag_keys=_state.value.keys()
|
111
|
+
)
|
112
|
+
aws_api.organizations.tag_resource(resource_id=uid, tags=tags)
|
113
|
+
|
114
|
+
def _get_destination_ou(
|
115
|
+
self, aws_api: AWSApi, destination_path: str
|
116
|
+
) -> AwsOrganizationOU:
|
117
|
+
org_tree_root = aws_api.organizations.get_organizational_units_tree()
|
118
|
+
return org_tree_root.find(destination_path)
|
119
|
+
|
120
|
+
def _move_account(self, aws_api: AWSApi, name: str, uid: str, ou: str) -> None:
|
121
|
+
with self.state.transaction(state_key(name, TASK_MOVE_ACCOUNT)) as _state:
|
122
|
+
if _state.exists and _state.value == ou:
|
123
|
+
# account already moved, nothing to do
|
124
|
+
return
|
125
|
+
|
126
|
+
logging.info(f"Moving account {name} to {ou}")
|
127
|
+
destination = self._get_destination_ou(aws_api, destination_path=ou)
|
128
|
+
if self.dry_run:
|
129
|
+
raise AbortStateTransaction("Dry run")
|
130
|
+
|
131
|
+
aws_api.organizations.move_account(
|
132
|
+
uid=uid,
|
133
|
+
destination_parent_id=destination.id,
|
134
|
+
)
|
135
|
+
_state.value = ou
|
136
|
+
|
137
|
+
def _set_account_alias(self, aws_api: AWSApi, name: str, alias: str | None) -> None:
|
138
|
+
"""Create an account alias."""
|
139
|
+
new_alias = alias or name
|
140
|
+
with self.state.transaction(
|
141
|
+
state_key(name, TASK_ACCOUNT_ALIAS), new_alias
|
142
|
+
) as _state:
|
143
|
+
if _state.exists and _state.value == new_alias:
|
144
|
+
return
|
145
|
+
|
146
|
+
logging.info(f"Set account alias '{new_alias}' for {name}")
|
147
|
+
if self.dry_run:
|
148
|
+
raise AbortStateTransaction("Dry run")
|
149
|
+
|
150
|
+
aws_api.iam.set_account_alias(account_alias=new_alias)
|
151
|
+
|
152
|
+
def _request_quotas(
|
153
|
+
self, aws_api: AWSApi, name: str, quotas: Iterable[Quota]
|
154
|
+
) -> list[str] | None:
|
155
|
+
"""Request service quota changes."""
|
156
|
+
quotas_dict = [q.dict() for q in quotas]
|
157
|
+
with self.state.transaction(
|
158
|
+
state_key(name, TASK_REQUEST_SERVICE_QUOTA)
|
159
|
+
) as _state:
|
160
|
+
if _state.exists and _state.value["last_applied_quotas"] == quotas_dict:
|
161
|
+
return _state.value["ids"]
|
162
|
+
|
163
|
+
# ATTENTION: reverting previously applied quotas or lowering them is not supported
|
164
|
+
new_quotas = []
|
165
|
+
for q in quotas:
|
166
|
+
quota = aws_api.service_quotas.get_service_quota(
|
167
|
+
service_code=q.service_code, quota_code=q.quota_code
|
168
|
+
)
|
169
|
+
if quota.value > q.value:
|
170
|
+
# a quota can be already higher than requested, because it was may set manually or enforced by the payer account
|
171
|
+
logging.info(
|
172
|
+
f"Cannot lower quota {q.service_code=}, {q.quota_code=}: {quota.value} -> {q.value}. Skipping."
|
173
|
+
)
|
174
|
+
elif quota.value < q.value:
|
175
|
+
quota.value = q.value
|
176
|
+
new_quotas.append(quota)
|
177
|
+
|
178
|
+
for q in new_quotas:
|
179
|
+
logging.info(
|
180
|
+
f"Setting quota for {name}: {q.service_name}/{q.quota_name} ({q.service_code}/{q.quota_code}) -> {q.value}"
|
181
|
+
)
|
182
|
+
|
183
|
+
if self.dry_run:
|
184
|
+
raise AbortStateTransaction("Dry run")
|
185
|
+
|
186
|
+
ids = []
|
187
|
+
for new_quota in new_quotas:
|
188
|
+
try:
|
189
|
+
req = aws_api.service_quotas.request_service_quota_change(
|
190
|
+
service_code=new_quota.service_code,
|
191
|
+
quota_code=new_quota.quota_code,
|
192
|
+
desired_value=new_quota.value,
|
193
|
+
)
|
194
|
+
except AWSResourceAlreadyExistsException:
|
195
|
+
raise AbortStateTransaction(
|
196
|
+
f"A quota increase for this {new_quota.service_code}/{new_quota.quota_code} already exists. Try it again later."
|
197
|
+
)
|
198
|
+
ids.append(req.id)
|
199
|
+
|
200
|
+
_state.value = {"last_applied_quotas": quotas_dict, "ids": ids}
|
201
|
+
return ids
|
202
|
+
|
203
|
+
def _check_quota_change_requests(
|
204
|
+
self,
|
205
|
+
aws_api: AWSApi,
|
206
|
+
name: str,
|
207
|
+
request_ids: Iterable[str],
|
208
|
+
) -> None:
|
209
|
+
"""Check the status of the quota change requests."""
|
210
|
+
with self.state.transaction(
|
211
|
+
state_key(name, TASK_CHECK_SERVICE_QUOTA_STATUS)
|
212
|
+
) as _state:
|
213
|
+
if _state.exists and _state.value == request_ids:
|
214
|
+
return
|
215
|
+
|
216
|
+
logging.info(f"Checking quota change requests for {name}")
|
217
|
+
if self.dry_run:
|
218
|
+
raise AbortStateTransaction("Dry run")
|
219
|
+
|
220
|
+
_state.value = []
|
221
|
+
for request_id in request_ids:
|
222
|
+
req = aws_api.service_quotas.get_requested_service_quota_change(
|
223
|
+
request_id=request_id
|
224
|
+
)
|
225
|
+
match req.status:
|
226
|
+
case "CASE_CLOSED" | "APPROVED":
|
227
|
+
_state.value.append(request_id)
|
228
|
+
case "DENIED" | "INVALID_REQUEST" | "NOT_APPROVED":
|
229
|
+
raise RuntimeError(
|
230
|
+
f"Quota change request {request_id} failed: {req.status}"
|
231
|
+
)
|
232
|
+
case _:
|
233
|
+
# everything else is considered in progress
|
234
|
+
pass
|
235
|
+
|
236
|
+
def _enable_enterprise_support(
|
237
|
+
self, aws_api: AWSApi, name: str, uid: str
|
238
|
+
) -> str | None:
|
239
|
+
"""Enable enterprise support for the account."""
|
240
|
+
with self.state.transaction(
|
241
|
+
state_key(name, TASK_ENABLE_ENTERPRISE_SUPPORT), ""
|
242
|
+
) as _state:
|
243
|
+
if _state.exists:
|
244
|
+
return _state.value
|
245
|
+
|
246
|
+
if aws_api.support.get_support_level() == SUPPORT_PLAN.ENTERPRISE:
|
247
|
+
if self.dry_run:
|
248
|
+
raise AbortStateTransaction("Dry run")
|
249
|
+
return None
|
250
|
+
|
251
|
+
logging.info(f"Enabling enterprise support for {name}")
|
252
|
+
if self.dry_run:
|
253
|
+
raise AbortStateTransaction("Dry run")
|
254
|
+
|
255
|
+
case_id = aws_api.support.create_case(
|
256
|
+
subject=f"Add account {uid} to Enterprise Support",
|
257
|
+
message=dedent(f"""
|
258
|
+
Hello AWS,
|
259
|
+
|
260
|
+
Please enable Enterprise Support on AWS account {uid} and resolve this support case.
|
261
|
+
|
262
|
+
Thanks.
|
263
|
+
|
264
|
+
[rh-internal-account-name: {name}]
|
265
|
+
"""),
|
266
|
+
)
|
267
|
+
_state.value = case_id
|
268
|
+
return case_id
|
269
|
+
|
270
|
+
def _check_enterprise_support_status(self, aws_api: AWSApi, case_id: str) -> None:
|
271
|
+
"""Check the status of the enterprise support case."""
|
272
|
+
with self.state.transaction(
|
273
|
+
state_key(case_id, TASK_CHECK_ENTERPRISE_SUPPORT_STATUS), True
|
274
|
+
) as _state:
|
275
|
+
if _state.exists:
|
276
|
+
return
|
277
|
+
|
278
|
+
logging.info(f"Checking enterprise support case {case_id}")
|
279
|
+
if self.dry_run:
|
280
|
+
raise AbortStateTransaction("Dry run")
|
281
|
+
|
282
|
+
case = aws_api.support.describe_case(case_id=case_id)
|
283
|
+
if case.status == "resolved":
|
284
|
+
return
|
285
|
+
|
286
|
+
logging.info(
|
287
|
+
f"Enterprise support case {case_id} is still open. Current status: {case.status}"
|
288
|
+
)
|
289
|
+
raise AbortStateTransaction("Enterprise support case still open")
|
290
|
+
|
291
|
+
#
|
292
|
+
# Public methods
|
293
|
+
#
|
294
|
+
def create_organization_account(
|
295
|
+
self, aws_api: AWSApi, name: str, email: str
|
296
|
+
) -> str | None:
|
297
|
+
"""Create an organization account and return the creation status ID."""
|
298
|
+
if create_account_request_id := self._create_account(aws_api, name, email):
|
299
|
+
if uid := self._org_account_exists(
|
300
|
+
aws_api, name, create_account_request_id
|
301
|
+
):
|
302
|
+
return uid
|
303
|
+
return None
|
304
|
+
|
305
|
+
def create_iam_user(
|
306
|
+
self,
|
307
|
+
aws_api: AWSApi,
|
308
|
+
name: str,
|
309
|
+
user_name: str,
|
310
|
+
user_policy_arn: str,
|
311
|
+
) -> AWSAccessKey | None:
|
312
|
+
"""Create an IAM user and return its access key."""
|
313
|
+
with self.state.transaction(
|
314
|
+
state_key(name, TASK_CREATE_IAM_USER), user_name
|
315
|
+
) as _state:
|
316
|
+
if _state.exists and _state.value == user_name:
|
317
|
+
return None
|
318
|
+
|
319
|
+
logging.info(f"Creating IAM user '{user_name}' for {name}")
|
320
|
+
if self.dry_run:
|
321
|
+
raise AbortStateTransaction("Dry run")
|
322
|
+
|
323
|
+
aws_api.iam.create_user(user_name=user_name)
|
324
|
+
aws_api.iam.attach_user_policy(
|
325
|
+
user_name=user_name,
|
326
|
+
policy_arn=user_policy_arn,
|
327
|
+
)
|
328
|
+
return aws_api.iam.create_access_key(user_name=user_name)
|
329
|
+
|
330
|
+
def reconcile_organization_account(
|
331
|
+
self,
|
332
|
+
aws_api: AWSApi,
|
333
|
+
name: str,
|
334
|
+
uid: str,
|
335
|
+
ou: str,
|
336
|
+
tags: dict[str, str],
|
337
|
+
enterprise_support: bool,
|
338
|
+
) -> None:
|
339
|
+
"""Reconcile the AWS account on the organization level."""
|
340
|
+
self._tag_account(aws_api, name, uid, tags)
|
341
|
+
self._move_account(aws_api, name, uid, ou)
|
342
|
+
if enterprise_support and (
|
343
|
+
case_id := self._enable_enterprise_support(aws_api, name, uid)
|
344
|
+
):
|
345
|
+
self._check_enterprise_support_status(aws_api, case_id)
|
346
|
+
|
347
|
+
def reconcile_account(
|
348
|
+
self, aws_api: AWSApi, name: str, alias: str | None, quotas: Iterable[Quota]
|
349
|
+
) -> None:
|
350
|
+
"""Reconcile/update the AWS account. Return the initial user access key if a new user was created."""
|
351
|
+
self._set_account_alias(aws_api, name, alias)
|
352
|
+
if request_ids := self._request_quotas(aws_api, name, quotas):
|
353
|
+
self._check_quota_change_requests(aws_api, name, request_ids)
|
@@ -0,0 +1,38 @@
|
|
1
|
+
from collections import Counter
|
2
|
+
|
3
|
+
from reconcile.gql_definitions.aws_account_manager.aws_accounts import (
|
4
|
+
AWSAccountV1,
|
5
|
+
)
|
6
|
+
|
7
|
+
|
8
|
+
def validate(account: AWSAccountV1) -> bool:
|
9
|
+
"""Validate the account configurations."""
|
10
|
+
# check referenced quotas don't overlap
|
11
|
+
quotas = Counter([
|
12
|
+
(quota.service_code, quota.quota_code)
|
13
|
+
for quota_limit in account.quota_limits or []
|
14
|
+
for quota in quota_limit.quotas or []
|
15
|
+
])
|
16
|
+
errors = [
|
17
|
+
ValueError(
|
18
|
+
f"Quota service_code={service_code}, quota_code={quota_code} is referenced multiple times in account {account.name}"
|
19
|
+
)
|
20
|
+
for (service_code, quota_code), cnt in quotas.items()
|
21
|
+
if cnt > 1
|
22
|
+
]
|
23
|
+
if errors:
|
24
|
+
raise ExceptionGroup("Multiple quotas are referenced in the account", errors)
|
25
|
+
|
26
|
+
if account.organization_accounts or account.account_requests:
|
27
|
+
# it's payer account
|
28
|
+
if not account.premium_support:
|
29
|
+
raise ValueError(
|
30
|
+
f"Premium support is required for payer account {account.name}"
|
31
|
+
)
|
32
|
+
|
33
|
+
return True
|
34
|
+
|
35
|
+
|
36
|
+
def state_key(account: str, task: str) -> str:
|
37
|
+
"""Compute a state key based on the organization account and the task name."""
|
38
|
+
return f"task.{account}.{task}"
|
@@ -76,6 +76,8 @@ class AwsSamlIdpIntegration(QontractReconcileIntegration[AwsSamlIdpIntegrationPa
|
|
76
76
|
for account in data.accounts or []
|
77
77
|
if integration_is_enabled(self.name, account)
|
78
78
|
and (not account_name or account.name == account_name)
|
79
|
+
# a new account does not have a terraform state yet, ignore it until terraform-init does its job
|
80
|
+
and account.terraform_state
|
79
81
|
]
|
80
82
|
|
81
83
|
def build_saml_idp_config(
|
@@ -19,6 +19,7 @@ from reconcile.aws_version_sync.merge_request_manager.merge_request import (
|
|
19
19
|
)
|
20
20
|
from reconcile.aws_version_sync.merge_request_manager.merge_request_manager import (
|
21
21
|
MergeRequestManager,
|
22
|
+
MrData,
|
22
23
|
)
|
23
24
|
from reconcile.aws_version_sync.utils import (
|
24
25
|
get_values,
|
@@ -337,8 +338,6 @@ class AVSIntegration(QontractReconcileIntegration[AVSIntegrationParams]):
|
|
337
338
|
external_resources_aws: AwsExternalResources,
|
338
339
|
external_resources_app_interface: AppInterfaceExternalResources,
|
339
340
|
) -> None:
|
340
|
-
# initialize the merge request manager
|
341
|
-
merge_request_manager.fetch_avs_managed_open_merge_requests()
|
342
341
|
# housekeeping: close old/bad MRs
|
343
342
|
merge_request_manager.housekeeping()
|
344
343
|
diff = diff_iterables(
|
@@ -361,15 +360,17 @@ class AVSIntegration(QontractReconcileIntegration[AVSIntegrationParams]):
|
|
361
360
|
# make mypy happy
|
362
361
|
assert app_interface_resource.namespace_file
|
363
362
|
assert app_interface_resource.provisioner.path
|
364
|
-
merge_request_manager.
|
365
|
-
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
363
|
+
merge_request_manager.create_merge_request(
|
364
|
+
MrData(
|
365
|
+
namespace_file=app_interface_resource.namespace_file,
|
366
|
+
provider=app_interface_resource.provider,
|
367
|
+
provisioner_ref=app_interface_resource.provisioner.path,
|
368
|
+
provisioner_uid=app_interface_resource.provisioner.uid,
|
369
|
+
resource_provider=app_interface_resource.resource_provider,
|
370
|
+
resource_identifier=app_interface_resource.resource_identifier,
|
371
|
+
resource_engine=app_interface_resource.resource_engine,
|
372
|
+
resource_engine_version=aws_resource.resource_engine_version_string,
|
373
|
+
)
|
373
374
|
)
|
374
375
|
|
375
376
|
@defer
|
@@ -1,9 +1,7 @@
|
|
1
1
|
import logging
|
2
|
-
import re
|
3
|
-
from dataclasses import dataclass
|
4
2
|
|
5
3
|
from gitlab.exceptions import GitlabGetError
|
6
|
-
from
|
4
|
+
from pydantic import BaseModel
|
7
5
|
|
8
6
|
from reconcile.aws_version_sync.merge_request_manager.merge_request import (
|
9
7
|
AVS_LABEL,
|
@@ -12,18 +10,14 @@ from reconcile.aws_version_sync.merge_request_manager.merge_request import (
|
|
12
10
|
Renderer,
|
13
11
|
)
|
14
12
|
from reconcile.utils.gitlab_api import GitLabApi
|
15
|
-
from reconcile.utils.merge_request_manager.
|
13
|
+
from reconcile.utils.merge_request_manager.merge_request_manager import (
|
14
|
+
MergeRequestManagerBase,
|
15
|
+
)
|
16
16
|
from reconcile.utils.mr.base import MergeRequestBase
|
17
17
|
from reconcile.utils.mr.labels import AUTO_MERGE
|
18
18
|
from reconcile.utils.vcs import VCS
|
19
19
|
|
20
20
|
|
21
|
-
@dataclass
|
22
|
-
class OpenMergeRequest:
|
23
|
-
raw: ProjectMergeRequest
|
24
|
-
avs_info: AVSInfo
|
25
|
-
|
26
|
-
|
27
21
|
class AVSMR(MergeRequestBase):
|
28
22
|
name = "AVS"
|
29
23
|
|
@@ -54,7 +48,18 @@ class AVSMR(MergeRequestBase):
|
|
54
48
|
)
|
55
49
|
|
56
50
|
|
57
|
-
class
|
51
|
+
class MrData(BaseModel):
|
52
|
+
namespace_file: str
|
53
|
+
provider: str
|
54
|
+
provisioner_ref: str
|
55
|
+
provisioner_uid: str
|
56
|
+
resource_provider: str
|
57
|
+
resource_identifier: str
|
58
|
+
resource_engine: str
|
59
|
+
resource_engine_version: str
|
60
|
+
|
61
|
+
|
62
|
+
class MergeRequestManager(MergeRequestManagerBase[AVSInfo]):
|
58
63
|
"""
|
59
64
|
Manager for AVS merge requests. This class
|
60
65
|
is responsible for housekeeping (closing old/bad MRs) and
|
@@ -68,113 +73,35 @@ class MergeRequestManager:
|
|
68
73
|
def __init__(
|
69
74
|
self, vcs: VCS, renderer: Renderer, parser: Parser, auto_merge_enabled: bool
|
70
75
|
):
|
71
|
-
|
72
|
-
self._open_mrs: list[OpenMergeRequest] = []
|
73
|
-
self._open_mrs_with_problems: list[OpenMergeRequest] = []
|
74
|
-
self._open_raw_mrs: list[ProjectMergeRequest] = []
|
76
|
+
super().__init__(vcs, parser, AVS_LABEL)
|
75
77
|
self._renderer = renderer
|
76
|
-
self._parser = parser
|
77
78
|
self._auto_merge_enabled = auto_merge_enabled
|
78
79
|
|
79
|
-
def
|
80
|
-
matches = pattern.search(promotion_data)
|
81
|
-
if not matches:
|
82
|
-
return ""
|
83
|
-
groups = matches.groups()
|
84
|
-
if len(groups) != 1:
|
85
|
-
return ""
|
86
|
-
return groups[0]
|
87
|
-
|
88
|
-
def fetch_avs_managed_open_merge_requests(self) -> None:
|
89
|
-
all_open_mrs = self._vcs.get_open_app_interface_merge_requests()
|
90
|
-
self._open_raw_mrs = [mr for mr in all_open_mrs if AVS_LABEL in mr.labels]
|
91
|
-
|
92
|
-
def housekeeping(self) -> None:
|
93
|
-
"""
|
94
|
-
Close bad MRs:
|
95
|
-
- bad description format
|
96
|
-
- old AVS version
|
97
|
-
- merge conflict
|
98
|
-
|
99
|
-
--> if we bump the AVS version, we automatically close
|
100
|
-
old open MRs and replace them with new ones.
|
101
|
-
"""
|
102
|
-
for mr in self._open_raw_mrs:
|
103
|
-
attrs = mr.attributes
|
104
|
-
desc = attrs.get("description")
|
105
|
-
has_conflicts = attrs.get("has_conflicts", False)
|
106
|
-
if has_conflicts:
|
107
|
-
logging.info(
|
108
|
-
"Merge-conflict detected. Closing %s",
|
109
|
-
mr.attributes.get("web_url", "NO_WEBURL"),
|
110
|
-
)
|
111
|
-
self._vcs.close_app_interface_mr(
|
112
|
-
mr, "Closing this MR because of a merge-conflict."
|
113
|
-
)
|
114
|
-
continue
|
115
|
-
try:
|
116
|
-
avs_info = self._parser.parse(description=desc)
|
117
|
-
except ParserVersionError:
|
118
|
-
logging.info(
|
119
|
-
"Old MR version detected! Closing %s",
|
120
|
-
mr.attributes.get("web_url", "NO_WEBURL"),
|
121
|
-
)
|
122
|
-
self._vcs.close_app_interface_mr(
|
123
|
-
mr, "Closing this MR because it has an outdated AVS version"
|
124
|
-
)
|
125
|
-
continue
|
126
|
-
except ParserError:
|
127
|
-
logging.info(
|
128
|
-
"Bad MR description format. Closing %s",
|
129
|
-
mr.attributes.get("web_url", "NO_WEBURL"),
|
130
|
-
)
|
131
|
-
self._vcs.close_app_interface_mr(
|
132
|
-
mr, "Closing this MR because of bad description format."
|
133
|
-
)
|
134
|
-
continue
|
135
|
-
|
136
|
-
self._open_mrs.append(OpenMergeRequest(raw=mr, avs_info=avs_info))
|
137
|
-
|
138
|
-
def _merge_request_already_exists(
|
80
|
+
def create_merge_request(
|
139
81
|
self,
|
140
|
-
|
141
|
-
account_id: str,
|
142
|
-
resource_provider: str,
|
143
|
-
resource_identifier: str,
|
144
|
-
resource_engine: str,
|
145
|
-
) -> OpenMergeRequest | None:
|
146
|
-
for mr in self._open_mrs:
|
147
|
-
if (
|
148
|
-
mr.avs_info.provider == provider
|
149
|
-
and mr.avs_info.account_id == account_id
|
150
|
-
and mr.avs_info.resource_provider == resource_provider
|
151
|
-
and mr.avs_info.resource_identifier == resource_identifier
|
152
|
-
and mr.avs_info.resource_engine == resource_engine
|
153
|
-
):
|
154
|
-
return mr
|
155
|
-
|
156
|
-
return None
|
157
|
-
|
158
|
-
def create_avs_merge_request(
|
159
|
-
self,
|
160
|
-
namespace_file: str,
|
161
|
-
provider: str,
|
162
|
-
provisioner_ref: str,
|
163
|
-
provisioner_uid: str,
|
164
|
-
resource_provider: str,
|
165
|
-
resource_identifier: str,
|
166
|
-
resource_engine: str,
|
167
|
-
resource_engine_version: str,
|
82
|
+
data: MrData,
|
168
83
|
) -> None:
|
169
84
|
"""Open new MR (if not already present) for an external resource and close any outdated before."""
|
170
|
-
if
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
175
|
-
|
176
|
-
|
177
|
-
|
85
|
+
if not self._housekeeping_ran:
|
86
|
+
self.housekeeping()
|
87
|
+
|
88
|
+
namespace_file = data.namespace_file
|
89
|
+
provider = data.provider
|
90
|
+
provisioner_ref = data.provisioner_ref
|
91
|
+
provisioner_uid = data.provisioner_uid
|
92
|
+
resource_provider = data.resource_provider
|
93
|
+
resource_identifier = data.resource_identifier
|
94
|
+
resource_engine = data.resource_engine
|
95
|
+
resource_engine_version = data.resource_engine_version
|
96
|
+
|
97
|
+
if mr := self._merge_request_already_exists({
|
98
|
+
"provider": provider,
|
99
|
+
"account_id": provisioner_uid,
|
100
|
+
"resource_provider": resource_provider,
|
101
|
+
"resource_identifier": resource_identifier,
|
102
|
+
"resource_engine": resource_engine,
|
103
|
+
}):
|
104
|
+
if mr.mr_info.resource_engine_version == resource_engine_version:
|
178
105
|
# an MR for this external resource already exists
|
179
106
|
return None
|
180
107
|
logging.info(
|