imbi-plugin-aws 0.1.0__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.
- imbi_plugin_aws/__init__.py +21 -0
- imbi_plugin_aws/_helpers.py +31 -0
- imbi_plugin_aws/account_resolution.py +138 -0
- imbi_plugin_aws/aws_session.py +324 -0
- imbi_plugin_aws/cloudwatch.py +1071 -0
- imbi_plugin_aws/errors.py +42 -0
- imbi_plugin_aws/identity.py +633 -0
- imbi_plugin_aws/log_groups.py +137 -0
- imbi_plugin_aws/models.py +35 -0
- imbi_plugin_aws/query.py +219 -0
- imbi_plugin_aws/ssm.py +409 -0
- imbi_plugin_aws-0.1.0.dist-info/METADATA +99 -0
- imbi_plugin_aws-0.1.0.dist-info/RECORD +16 -0
- imbi_plugin_aws-0.1.0.dist-info/WHEEL +4 -0
- imbi_plugin_aws-0.1.0.dist-info/entry_points.txt +4 -0
- imbi_plugin_aws-0.1.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""AWS plugins for Imbi.
|
|
2
|
+
|
|
3
|
+
Ships three plugin entry points from a single distribution:
|
|
4
|
+
|
|
5
|
+
* ``aws-iam-ic`` -- :class:`IdentityPlugin` for AWS IAM Identity Center.
|
|
6
|
+
* ``aws-ssm`` -- :class:`ConfigurationPlugin` backed by SSM Parameter
|
|
7
|
+
Store.
|
|
8
|
+
* ``aws-cloudwatch-logs`` -- :class:`LogsPlugin` backed by CloudWatch
|
|
9
|
+
Logs Insights.
|
|
10
|
+
|
|
11
|
+
The data plugins consume credentials from a flat ``dict[str, str]``
|
|
12
|
+
populated by either the ``ServiceApplication`` static-key path or the
|
|
13
|
+
identity-hydrated path that calls ``aws-iam-ic.materialize()``;
|
|
14
|
+
neither plugin needs to know which source applied.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from imbi_plugin_aws.cloudwatch import CloudWatchLogsPlugin
|
|
18
|
+
from imbi_plugin_aws.identity import AwsIamIcPlugin
|
|
19
|
+
from imbi_plugin_aws.ssm import SsmPlugin
|
|
20
|
+
|
|
21
|
+
__all__ = ['AwsIamIcPlugin', 'CloudWatchLogsPlugin', 'SsmPlugin']
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Shared utilities for the AWS data plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from imbi_common.plugins.base import PluginContext
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def template_vars(ctx: PluginContext) -> dict[str, str | None]:
|
|
9
|
+
"""Whitelisted template variables for plugin option expansion."""
|
|
10
|
+
return {
|
|
11
|
+
'project_slug': ctx.project_slug,
|
|
12
|
+
'org_slug': ctx.org_slug,
|
|
13
|
+
'team_slug': ctx.team_slug,
|
|
14
|
+
'environment': ctx.environment,
|
|
15
|
+
'project_id': ctx.project_id,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assignment_region(ctx: PluginContext) -> str | None:
|
|
20
|
+
region = ctx.assignment_options.get('region')
|
|
21
|
+
return str(region) if region else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def assignment_timeout(
|
|
25
|
+
ctx: PluginContext, *, default: float, key: str = 'timeout_seconds'
|
|
26
|
+
) -> float:
|
|
27
|
+
raw = ctx.assignment_options.get(key)
|
|
28
|
+
try:
|
|
29
|
+
return float(raw) if raw is not None else default
|
|
30
|
+
except (TypeError, ValueError):
|
|
31
|
+
return default
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""``MAPS_TO`` traversal helper for the AWS plugin.
|
|
2
|
+
|
|
3
|
+
At call time, ``materialize`` (or any sibling plugin's handler) needs to
|
|
4
|
+
pick a single :class:`imbi_plugin_aws.models.AwsAccount` from the
|
|
5
|
+
actor's context. Operators model the mapping declaratively as
|
|
6
|
+
``(:Environment | :Project | :ProjectType | :Organization)-[:MAPS_TO]->
|
|
7
|
+
(:AwsAccount)`` edges; this module walks them in selector order and
|
|
8
|
+
returns the first match.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import typing
|
|
15
|
+
|
|
16
|
+
from imbi_common.plugins.base import PluginContext
|
|
17
|
+
|
|
18
|
+
from imbi_plugin_aws.errors import AccountNotResolvedError
|
|
19
|
+
from imbi_plugin_aws.models import AwsAccount
|
|
20
|
+
|
|
21
|
+
LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
DEFAULT_SELECTOR: list[str] = [
|
|
24
|
+
'project',
|
|
25
|
+
'environment',
|
|
26
|
+
'project_type',
|
|
27
|
+
'organization',
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
_LABEL_FOR_ANCHOR: dict[str, str] = {
|
|
31
|
+
'project': 'Project',
|
|
32
|
+
'environment': 'Environment',
|
|
33
|
+
'project_type': 'ProjectType',
|
|
34
|
+
'organization': 'Organization',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def resolve_account(
|
|
39
|
+
db: typing.Any,
|
|
40
|
+
ctx: PluginContext,
|
|
41
|
+
options: dict[str, typing.Any],
|
|
42
|
+
) -> AwsAccount:
|
|
43
|
+
"""Return the first ``AwsAccount`` reachable via ``MAPS_TO`` from
|
|
44
|
+
the actor's context anchors, in selector order.
|
|
45
|
+
|
|
46
|
+
Tag filters narrow the candidate set per anchor: each candidate's
|
|
47
|
+
``tags`` dict must be a superset of every entry in
|
|
48
|
+
``options['tag_filters']``.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
AccountNotResolvedError: No anchor produced a matching account.
|
|
52
|
+
"""
|
|
53
|
+
selector = list(options.get('account_selector', DEFAULT_SELECTOR))
|
|
54
|
+
tag_filters: dict[str, str] = options.get('tag_filters', {}) or {}
|
|
55
|
+
|
|
56
|
+
anchors_checked: list[str] = []
|
|
57
|
+
for anchor in selector:
|
|
58
|
+
label = _LABEL_FOR_ANCHOR.get(anchor)
|
|
59
|
+
if label is None:
|
|
60
|
+
continue
|
|
61
|
+
anchor_id = _anchor_id_from_ctx(anchor, ctx)
|
|
62
|
+
if not anchor_id:
|
|
63
|
+
continue
|
|
64
|
+
anchors_checked.append(anchor)
|
|
65
|
+
candidates = await _candidates_for_anchor(db, label, anchor_id)
|
|
66
|
+
for candidate in candidates:
|
|
67
|
+
if _matches_tag_filters(candidate.tags, tag_filters):
|
|
68
|
+
return candidate
|
|
69
|
+
|
|
70
|
+
raise AccountNotResolvedError(
|
|
71
|
+
selector=selector,
|
|
72
|
+
anchors_checked=anchors_checked,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _anchor_id_from_ctx(anchor: str, ctx: PluginContext) -> str | None:
|
|
77
|
+
"""Look up the id of ``anchor`` from the request's
|
|
78
|
+
:class:`PluginContext`.
|
|
79
|
+
|
|
80
|
+
The host populates ``project_id`` / ``project_slug`` / ``org_slug``
|
|
81
|
+
today; ``environment`` is a slug, not an id, and ``project_type`` is
|
|
82
|
+
not yet on the context — those anchors are no-ops in Phase 1 and
|
|
83
|
+
will be reachable once the host extends ``PluginContext``.
|
|
84
|
+
"""
|
|
85
|
+
if anchor == 'project':
|
|
86
|
+
return ctx.project_id or None
|
|
87
|
+
# Environment / project_type / organization arrive as slugs from
|
|
88
|
+
# the host, not ids; the calling plugin must perform the slug→id
|
|
89
|
+
# lookup before passing them in. Phase 1 only resolves via
|
|
90
|
+
# project_id; the other anchors return None.
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
async def _candidates_for_anchor(
|
|
95
|
+
db: typing.Any,
|
|
96
|
+
label: str,
|
|
97
|
+
anchor_id: str,
|
|
98
|
+
) -> list[AwsAccount]:
|
|
99
|
+
"""Walk ``MAPS_TO`` from a labeled node id to its ``AwsAccount``
|
|
100
|
+
targets.
|
|
101
|
+
|
|
102
|
+
Uses the same Cypher template the host uses elsewhere — ``{}``
|
|
103
|
+
placeholders are SQL-format-style, ``{{`` / ``}}`` escape Cypher
|
|
104
|
+
map literals.
|
|
105
|
+
"""
|
|
106
|
+
query = (
|
|
107
|
+
'MATCH (n:' + label + ' {{id: {anchor_id}}})'
|
|
108
|
+
'-[:MAPS_TO]->(a:AwsAccount)'
|
|
109
|
+
' RETURN a{{.*}} AS account'
|
|
110
|
+
)
|
|
111
|
+
rows = await db.execute(query, {'anchor_id': anchor_id}, ['account'])
|
|
112
|
+
accounts: list[AwsAccount] = []
|
|
113
|
+
for row in rows:
|
|
114
|
+
raw = row.get('account')
|
|
115
|
+
if raw is None:
|
|
116
|
+
continue
|
|
117
|
+
# ``parse_agtype`` is the host's helper. Imported lazily to
|
|
118
|
+
# avoid coupling this module to imbi_common's import order
|
|
119
|
+
# in tests that stub the db.
|
|
120
|
+
try:
|
|
121
|
+
from imbi_common.graph import parse_agtype
|
|
122
|
+
except ImportError:
|
|
123
|
+
data = raw
|
|
124
|
+
else:
|
|
125
|
+
data = parse_agtype(raw)
|
|
126
|
+
try:
|
|
127
|
+
accounts.append(AwsAccount.model_validate(data))
|
|
128
|
+
except Exception: # noqa: BLE001
|
|
129
|
+
LOGGER.warning(
|
|
130
|
+
'AwsAccount row %r failed validation', data, exc_info=True
|
|
131
|
+
)
|
|
132
|
+
return accounts
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _matches_tag_filters(
|
|
136
|
+
tags: dict[str, str], tag_filters: dict[str, str]
|
|
137
|
+
) -> bool:
|
|
138
|
+
return all(tags.get(k) == v for k, v in tag_filters.items())
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Shared AWS credentials + JSON-protocol client for the SSM and
|
|
2
|
+
CloudWatch Logs plugins.
|
|
3
|
+
|
|
4
|
+
We deliberately stay on ``httpx`` (already used by the IAM IC identity
|
|
5
|
+
plugin) and implement a minimal AWS SigV4 signer so the test suite can
|
|
6
|
+
keep using ``respx`` for mocking. Both SSM and CloudWatch Logs speak
|
|
7
|
+
the JSON-1.1 wire protocol, so a single helper covers both surfaces.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime
|
|
13
|
+
import hashlib
|
|
14
|
+
import hmac
|
|
15
|
+
import json
|
|
16
|
+
import typing
|
|
17
|
+
import urllib.parse
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
from imbi_common.plugins.base import PluginContext
|
|
21
|
+
from imbi_common.plugins.errors import (
|
|
22
|
+
PluginAuthenticationFailed,
|
|
23
|
+
PluginCredentialsMissing,
|
|
24
|
+
PluginTimeoutError,
|
|
25
|
+
PluginUnavailableError,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
ServiceName = typing.Literal['ssm', 'logs']
|
|
29
|
+
|
|
30
|
+
_SIGNING_ALGORITHM = 'AWS4-HMAC-SHA256'
|
|
31
|
+
_AWS_REQUEST = 'aws4_request'
|
|
32
|
+
|
|
33
|
+
_TARGET_PREFIX: dict[ServiceName, str] = {
|
|
34
|
+
'ssm': 'AmazonSSM',
|
|
35
|
+
'logs': 'Logs_20140328',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# AWS error codes that indicate the caller's credentials are no longer
|
|
39
|
+
# accepted by the upstream service. Refreshing the IAM IC session and
|
|
40
|
+
# retrying the call once is a reasonable recovery, so the host's retry
|
|
41
|
+
# layer is given a chance via :class:`PluginAuthenticationFailed`.
|
|
42
|
+
_AUTH_ERROR_CODES: frozenset[str] = frozenset(
|
|
43
|
+
{
|
|
44
|
+
'ExpiredToken',
|
|
45
|
+
'ExpiredTokenException',
|
|
46
|
+
'InvalidClientTokenId',
|
|
47
|
+
'UnauthorizedException',
|
|
48
|
+
'UnrecognizedClientException',
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AwsCredentials(typing.NamedTuple):
|
|
54
|
+
"""A validated set of AWS credentials + region."""
|
|
55
|
+
|
|
56
|
+
access_key_id: str
|
|
57
|
+
secret_access_key: str
|
|
58
|
+
session_token: str | None
|
|
59
|
+
region: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_AWS_IDENTITY_KEYS = (
|
|
63
|
+
'aws_access_key_id',
|
|
64
|
+
'aws_secret_access_key',
|
|
65
|
+
'aws_session_token',
|
|
66
|
+
'aws_region',
|
|
67
|
+
'aws_account_id',
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_credentials(
|
|
72
|
+
credentials: dict[str, str],
|
|
73
|
+
*,
|
|
74
|
+
region: str | None = None,
|
|
75
|
+
ctx: PluginContext | None = None,
|
|
76
|
+
) -> AwsCredentials:
|
|
77
|
+
"""Validate the credentials dict and resolve the effective region.
|
|
78
|
+
|
|
79
|
+
Two credential sources are supported and combined here:
|
|
80
|
+
|
|
81
|
+
* ``credentials`` — static keys stored on the ``Plugin`` node by
|
|
82
|
+
the operator (``api_token``-style auth).
|
|
83
|
+
* ``ctx.identity.extra`` — STS keys minted by an identity plugin
|
|
84
|
+
(currently ``aws-iam-ic``). The host attaches these to
|
|
85
|
+
:attr:`PluginContext.identity` via ``hydrate_identity`` and the
|
|
86
|
+
data plugin must consult them, since the host does not merge
|
|
87
|
+
identity creds into the ``credentials`` dict.
|
|
88
|
+
|
|
89
|
+
Identity-supplied keys take precedence when present (a connected
|
|
90
|
+
user's STS keys are always preferable to static fallbacks).
|
|
91
|
+
``region`` argument wins over both — it's the assignment option.
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
PluginCredentialsMissing: when either AWS key is missing or
|
|
95
|
+
when the region cannot be resolved.
|
|
96
|
+
"""
|
|
97
|
+
merged: dict[str, str] = {k: v for k, v in credentials.items() if v}
|
|
98
|
+
if ctx is not None and ctx.identity is not None:
|
|
99
|
+
for key in _AWS_IDENTITY_KEYS:
|
|
100
|
+
value = ctx.identity.extra.get(key)
|
|
101
|
+
if isinstance(value, str) and value:
|
|
102
|
+
merged[key] = value
|
|
103
|
+
|
|
104
|
+
access_key = merged.get('aws_access_key_id') or ''
|
|
105
|
+
secret_key = merged.get('aws_secret_access_key') or ''
|
|
106
|
+
if not access_key and not secret_key:
|
|
107
|
+
raise PluginCredentialsMissing(
|
|
108
|
+
'AWS credentials missing: aws_access_key_id and '
|
|
109
|
+
'aws_secret_access_key are required'
|
|
110
|
+
)
|
|
111
|
+
if not access_key or not secret_key:
|
|
112
|
+
raise PluginCredentialsMissing(
|
|
113
|
+
'AWS credentials malformed: both aws_access_key_id and '
|
|
114
|
+
'aws_secret_access_key must be provided'
|
|
115
|
+
)
|
|
116
|
+
resolved_region = region or merged.get('aws_region')
|
|
117
|
+
if not resolved_region:
|
|
118
|
+
raise PluginCredentialsMissing(
|
|
119
|
+
'AWS region missing: pass region explicitly or set '
|
|
120
|
+
'aws_region in credentials'
|
|
121
|
+
)
|
|
122
|
+
session_token = merged.get('aws_session_token') or None
|
|
123
|
+
return AwsCredentials(
|
|
124
|
+
access_key_id=access_key,
|
|
125
|
+
secret_access_key=secret_key,
|
|
126
|
+
session_token=session_token,
|
|
127
|
+
region=resolved_region,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _hmac(key: bytes, data: str) -> bytes:
|
|
132
|
+
return hmac.new(key, data.encode('utf-8'), hashlib.sha256).digest()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _signing_key(
|
|
136
|
+
secret_access_key: str, date_stamp: str, region: str, service: str
|
|
137
|
+
) -> bytes:
|
|
138
|
+
k_date = _hmac(f'AWS4{secret_access_key}'.encode(), date_stamp)
|
|
139
|
+
k_region = _hmac(k_date, region)
|
|
140
|
+
k_service = _hmac(k_region, service)
|
|
141
|
+
return _hmac(k_service, _AWS_REQUEST)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def sign_request(
|
|
145
|
+
*,
|
|
146
|
+
method: str,
|
|
147
|
+
url: str,
|
|
148
|
+
headers: dict[str, str],
|
|
149
|
+
body: bytes,
|
|
150
|
+
service: str,
|
|
151
|
+
credentials: AwsCredentials,
|
|
152
|
+
now: datetime.datetime | None = None,
|
|
153
|
+
) -> dict[str, str]:
|
|
154
|
+
"""Return signed request headers (SigV4) for an AWS JSON request.
|
|
155
|
+
|
|
156
|
+
The returned mapping is the *complete* set of headers to send: the
|
|
157
|
+
caller's ``headers`` plus ``Host``, ``X-Amz-Date``,
|
|
158
|
+
``X-Amz-Security-Token`` (when applicable), and ``Authorization``.
|
|
159
|
+
"""
|
|
160
|
+
parsed = urllib.parse.urlsplit(url)
|
|
161
|
+
host = parsed.netloc
|
|
162
|
+
canonical_uri = parsed.path or '/'
|
|
163
|
+
canonical_query = parsed.query
|
|
164
|
+
timestamp = (now or datetime.datetime.now(datetime.UTC)).strftime(
|
|
165
|
+
'%Y%m%dT%H%M%SZ'
|
|
166
|
+
)
|
|
167
|
+
date_stamp = timestamp[:8]
|
|
168
|
+
payload_hash = hashlib.sha256(body).hexdigest()
|
|
169
|
+
|
|
170
|
+
signed: dict[str, str] = {**headers, 'host': host, 'x-amz-date': timestamp}
|
|
171
|
+
if credentials.session_token:
|
|
172
|
+
signed['x-amz-security-token'] = credentials.session_token
|
|
173
|
+
|
|
174
|
+
canonical_headers_pairs = sorted(
|
|
175
|
+
(name.lower(), value.strip()) for name, value in signed.items()
|
|
176
|
+
)
|
|
177
|
+
canonical_headers = ''.join(
|
|
178
|
+
f'{name}:{value}\n' for name, value in canonical_headers_pairs
|
|
179
|
+
)
|
|
180
|
+
signed_header_names = ';'.join(name for name, _ in canonical_headers_pairs)
|
|
181
|
+
canonical_request = '\n'.join(
|
|
182
|
+
[
|
|
183
|
+
method.upper(),
|
|
184
|
+
canonical_uri,
|
|
185
|
+
canonical_query,
|
|
186
|
+
canonical_headers,
|
|
187
|
+
signed_header_names,
|
|
188
|
+
payload_hash,
|
|
189
|
+
]
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
credential_scope = (
|
|
193
|
+
f'{date_stamp}/{credentials.region}/{service}/{_AWS_REQUEST}'
|
|
194
|
+
)
|
|
195
|
+
string_to_sign = '\n'.join(
|
|
196
|
+
[
|
|
197
|
+
_SIGNING_ALGORITHM,
|
|
198
|
+
timestamp,
|
|
199
|
+
credential_scope,
|
|
200
|
+
hashlib.sha256(canonical_request.encode()).hexdigest(),
|
|
201
|
+
]
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
signature = hmac.new(
|
|
205
|
+
_signing_key(
|
|
206
|
+
credentials.secret_access_key,
|
|
207
|
+
date_stamp,
|
|
208
|
+
credentials.region,
|
|
209
|
+
service,
|
|
210
|
+
),
|
|
211
|
+
string_to_sign.encode(),
|
|
212
|
+
hashlib.sha256,
|
|
213
|
+
).hexdigest()
|
|
214
|
+
authorization = (
|
|
215
|
+
f'{_SIGNING_ALGORITHM} '
|
|
216
|
+
f'Credential={credentials.access_key_id}/{credential_scope}, '
|
|
217
|
+
f'SignedHeaders={signed_header_names}, '
|
|
218
|
+
f'Signature={signature}'
|
|
219
|
+
)
|
|
220
|
+
signed['Authorization'] = authorization
|
|
221
|
+
return signed
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _service_endpoint(service: ServiceName, region: str) -> str:
|
|
225
|
+
return f'https://{service}.{region}.amazonaws.com/'
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _extract_error_code(payload: dict[str, typing.Any]) -> str:
|
|
229
|
+
error_type = payload.get('__type') or payload.get('Code') or ''
|
|
230
|
+
if isinstance(error_type, str) and '#' in error_type:
|
|
231
|
+
error_type = error_type.split('#', 1)[1]
|
|
232
|
+
return str(error_type or '')
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _map_status_error(
|
|
236
|
+
status_code: int,
|
|
237
|
+
error_code: str,
|
|
238
|
+
message: str,
|
|
239
|
+
error_map: dict[str, type[Exception]],
|
|
240
|
+
) -> Exception:
|
|
241
|
+
"""Translate an AWS error into the appropriate Imbi exception.
|
|
242
|
+
|
|
243
|
+
Auth-failure error codes take precedence over the caller-supplied
|
|
244
|
+
``error_map`` so the host retry layer can refresh the actor's IAM
|
|
245
|
+
IC connection — historical maps surface these as
|
|
246
|
+
:class:`PluginCredentialsMissing`, which means "configure new
|
|
247
|
+
static creds" and would terminate the request before the refresh
|
|
248
|
+
has a chance to fire.
|
|
249
|
+
"""
|
|
250
|
+
if error_code in _AUTH_ERROR_CODES:
|
|
251
|
+
return PluginAuthenticationFailed(message or error_code)
|
|
252
|
+
if error_code in error_map:
|
|
253
|
+
return error_map[error_code](message or error_code)
|
|
254
|
+
if status_code >= 500:
|
|
255
|
+
return PluginUnavailableError(message or f'AWS {status_code}')
|
|
256
|
+
return PluginUnavailableError(
|
|
257
|
+
message or f'AWS error {error_code or status_code}'
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def call_aws_json(
|
|
262
|
+
*,
|
|
263
|
+
service: ServiceName,
|
|
264
|
+
action: str,
|
|
265
|
+
body: dict[str, typing.Any],
|
|
266
|
+
credentials: AwsCredentials,
|
|
267
|
+
error_map: dict[str, type[Exception]],
|
|
268
|
+
timeout: float = 15.0,
|
|
269
|
+
client: httpx.AsyncClient | None = None,
|
|
270
|
+
) -> dict[str, typing.Any]:
|
|
271
|
+
"""Invoke an AWS JSON-1.1 action and return the decoded response.
|
|
272
|
+
|
|
273
|
+
``error_map`` maps AWS error codes (e.g. ``ParameterNotFound``) to
|
|
274
|
+
the Imbi exception class to raise. Errors not in the map fall back
|
|
275
|
+
to :class:`PluginUnavailableError` for 5xx and a default of
|
|
276
|
+
:class:`PluginUnavailableError` otherwise.
|
|
277
|
+
"""
|
|
278
|
+
payload = json.dumps(body).encode()
|
|
279
|
+
target = f'{_TARGET_PREFIX[service]}.{action}'
|
|
280
|
+
base_headers: dict[str, str] = {
|
|
281
|
+
'Content-Type': 'application/x-amz-json-1.1',
|
|
282
|
+
'X-Amz-Target': target,
|
|
283
|
+
}
|
|
284
|
+
url = _service_endpoint(service, credentials.region)
|
|
285
|
+
signed_headers = sign_request(
|
|
286
|
+
method='POST',
|
|
287
|
+
url=url,
|
|
288
|
+
headers=base_headers,
|
|
289
|
+
body=payload,
|
|
290
|
+
service=service,
|
|
291
|
+
credentials=credentials,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
async def _do(http: httpx.AsyncClient) -> httpx.Response:
|
|
295
|
+
return await http.post(url, content=payload, headers=signed_headers)
|
|
296
|
+
|
|
297
|
+
try:
|
|
298
|
+
if client is None:
|
|
299
|
+
async with httpx.AsyncClient(timeout=timeout) as http:
|
|
300
|
+
response = await _do(http)
|
|
301
|
+
else:
|
|
302
|
+
response = await _do(client)
|
|
303
|
+
except (httpx.ConnectTimeout, httpx.ReadTimeout) as exc:
|
|
304
|
+
raise PluginTimeoutError(str(exc)) from exc
|
|
305
|
+
|
|
306
|
+
if response.status_code == 200:
|
|
307
|
+
if not response.content:
|
|
308
|
+
return {}
|
|
309
|
+
return typing.cast(dict[str, typing.Any], response.json())
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
err_payload = typing.cast(dict[str, typing.Any], response.json())
|
|
313
|
+
except ValueError:
|
|
314
|
+
err_payload = {}
|
|
315
|
+
error_code = _extract_error_code(err_payload)
|
|
316
|
+
error_message = str(
|
|
317
|
+
err_payload.get('message')
|
|
318
|
+
or err_payload.get('Message')
|
|
319
|
+
or response.text
|
|
320
|
+
or ''
|
|
321
|
+
)
|
|
322
|
+
raise _map_status_error(
|
|
323
|
+
response.status_code, error_code, error_message, error_map
|
|
324
|
+
)
|