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.
- {qontract_reconcile-0.10.1rc701.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/METADATA +1 -1
- {qontract_reconcile-0.10.1rc701.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/RECORD +20 -10
- 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/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/fragments/aws_account_managed.py +49 -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/state.py +42 -38
- {qontract_reconcile-0.10.1rc701.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/WHEEL +0 -0
- {qontract_reconcile-0.10.1rc701.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/entry_points.txt +0 -0
- {qontract_reconcile-0.10.1rc701.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
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
420
|
-
|
443
|
+
@dataclass
|
444
|
+
class TransactionStateObj:
|
445
|
+
"""Represents a transistion state object with a key and a value."""
|
421
446
|
|
422
|
-
|
423
|
-
|
424
|
-
|
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
|
433
|
-
|
451
|
+
def __post_init__(self) -> None:
|
452
|
+
self._init_value = self.value
|
434
453
|
|
435
|
-
|
436
|
-
|
437
|
-
self.
|
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
|
-
|
445
|
-
|
446
|
-
|
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
|
File without changes
|
File without changes
|
{qontract_reconcile-0.10.1rc701.dist-info → qontract_reconcile-0.10.1rc702.dist-info}/top_level.txt
RENAMED
File without changes
|