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
@@ -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.create_avs_merge_request(
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,
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 gitlab.v4.objects import ProjectMergeRequest
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.parser import ParserError, ParserVersionError
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 MergeRequestManager:
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
- self._vcs = vcs
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 _apply_regex(self, pattern: re.Pattern, promotion_data: str) -> str:
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
- provider: str,
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 mr := self._merge_request_already_exists(
171
- provider=provider,
172
- account_id=provisioner_uid,
173
- resource_provider=resource_provider,
174
- resource_identifier=resource_identifier,
175
- resource_engine=resource_engine,
176
- ):
177
- if mr.avs_info.resource_engine_version == resource_engine_version:
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(