qontract-reconcile 0.10.1rc701__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.
@@ -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
reconcile/utils/state.py CHANGED
@@ -1,9 +1,10 @@
1
+ import contextlib
1
2
  import json
2
3
  import logging
3
4
  import os
4
5
  from abc import abstractmethod
5
- from collections.abc import Callable, Mapping
6
- from types import TracebackType
6
+ from collections.abc import Callable, Generator, Mapping
7
+ from dataclasses import dataclass, field
7
8
  from typing import (
8
9
  Any,
9
10
  Optional,
@@ -212,6 +213,10 @@ def acquire_state_settings(
212
213
  )
213
214
 
214
215
 
216
+ class AbortStateTransaction(Exception):
217
+ """Raise to abort a state transaction."""
218
+
219
+
215
220
  class State:
216
221
  """
217
222
  A state object to be used by stateful integrations.
@@ -405,52 +410,51 @@ class State:
405
410
  def __setitem__(self, key: str, value: Any) -> None:
406
411
  self._set(key, value)
407
412
 
408
- def transaction(self, key: str, value: Any) -> "_TransactionContext":
413
+ @contextlib.contextmanager
414
+ def transaction(
415
+ self, key: str, value: Any = None
416
+ ) -> Generator["TransactionStateObj", None, None]:
409
417
  """Get a context manager to set the key in the state if no exception occurs.
410
418
 
419
+ You can set the value either via the value parameter or by setting the value attribute of the returned object.
420
+ If both are provided, the value attribute of the state object will take precedence.
421
+
411
422
  Attention!
412
423
 
413
424
  This is not a locking mechanism. It is a way to ensure that a key is set in the state if no exception occurs.
414
425
  This method is not thread-safe nor multi-process-safe! There is no locking mechanism in place.
415
426
  """
416
- return _TransactionContext(self, key, value)
427
+ try:
428
+ _current_value = self[key]
429
+ except KeyError:
430
+ _current_value = None
431
+ state_obj = TransactionStateObj(key, value=_current_value)
432
+ try:
433
+ yield state_obj
434
+ except AbortStateTransaction:
435
+ return
436
+ else:
437
+ if state_obj.changed and state_obj.value != _current_value:
438
+ self[state_obj.key] = state_obj.value
439
+ elif value is not None and state_obj.value != value:
440
+ self[state_obj.key] = value
417
441
 
418
442
 
419
- class _TransactionContext:
420
- """A context manager to set a key in the state if no exception occurs."""
443
+ @dataclass
444
+ class TransactionStateObj:
445
+ """Represents a transistion state object with a key and a value."""
421
446
 
422
- def __init__(
423
- self,
424
- state: State,
425
- key: str,
426
- value: Any,
427
- ):
428
- self.state = state
429
- self.key = key
430
- self.value = value
447
+ key: str
448
+ value: Any = None
449
+ _init_value: Any = field(init=False)
431
450
 
432
- def __enter__(self) -> bool:
433
- """Return True if the key exists in the state, False otherwise.
451
+ def __post_init__(self) -> None:
452
+ self._init_value = self.value
434
453
 
435
- Cache the previous value to avoid unnecessary updates.
436
- """
437
- self._previous_value = None
438
- try:
439
- self._previous_value = self.state[self.key]
440
- return True
441
- except KeyError:
442
- return False
454
+ @property
455
+ def changed(self) -> bool:
456
+ return self.value != self._init_value
443
457
 
444
- def __exit__(
445
- self,
446
- exc_type: type[BaseException] | None,
447
- exc_value: BaseException | None,
448
- traceback: TracebackType | None,
449
- ) -> None:
450
- if exc_type:
451
- # if an exception occurred, we don't want to write to the state
452
- return
453
- if self._previous_value == self.value:
454
- # if the value didn't change, we don't want to write to the state
455
- return
456
- self.state[self.key] = self.value
458
+ @property
459
+ def exists(self) -> bool:
460
+ return self._init_value is not None