zae-limiter 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.
- zae_limiter/__init__.py +130 -0
- zae_limiter/aggregator/__init__.py +11 -0
- zae_limiter/aggregator/handler.py +54 -0
- zae_limiter/aggregator/processor.py +270 -0
- zae_limiter/bucket.py +291 -0
- zae_limiter/cli.py +608 -0
- zae_limiter/exceptions.py +214 -0
- zae_limiter/infra/__init__.py +10 -0
- zae_limiter/infra/cfn_template.yaml +255 -0
- zae_limiter/infra/lambda_builder.py +85 -0
- zae_limiter/infra/stack_manager.py +536 -0
- zae_limiter/lease.py +196 -0
- zae_limiter/limiter.py +925 -0
- zae_limiter/migrations/__init__.py +114 -0
- zae_limiter/migrations/v1_0_0.py +55 -0
- zae_limiter/models.py +302 -0
- zae_limiter/repository.py +656 -0
- zae_limiter/schema.py +163 -0
- zae_limiter/version.py +214 -0
- zae_limiter-0.1.0.dist-info/METADATA +470 -0
- zae_limiter-0.1.0.dist-info/RECORD +24 -0
- zae_limiter-0.1.0.dist-info/WHEEL +4 -0
- zae_limiter-0.1.0.dist-info/entry_points.txt +2 -0
- zae_limiter-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""CloudFormation stack management for zae-limiter infrastructure."""
|
|
2
|
+
|
|
3
|
+
from importlib.resources import files
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
import aioboto3 # type: ignore
|
|
7
|
+
from botocore.exceptions import ClientError
|
|
8
|
+
|
|
9
|
+
from ..exceptions import StackAlreadyExistsError, StackCreationError
|
|
10
|
+
from .lambda_builder import build_lambda_package
|
|
11
|
+
|
|
12
|
+
# Version tag keys for infrastructure
|
|
13
|
+
VERSION_TAG_PREFIX = "zae-limiter:"
|
|
14
|
+
VERSION_TAG_KEY = f"{VERSION_TAG_PREFIX}version"
|
|
15
|
+
LAMBDA_VERSION_TAG_KEY = f"{VERSION_TAG_PREFIX}lambda-version"
|
|
16
|
+
SCHEMA_VERSION_TAG_KEY = f"{VERSION_TAG_PREFIX}schema-version"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class StackManager:
|
|
20
|
+
"""
|
|
21
|
+
Manages CloudFormation stack lifecycle for rate limiter infrastructure.
|
|
22
|
+
|
|
23
|
+
Auto-detects local DynamoDB environments (via endpoint_url) and
|
|
24
|
+
gracefully skips CloudFormation operations in those cases.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
table_name: str,
|
|
30
|
+
region: str | None = None,
|
|
31
|
+
endpoint_url: str | None = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""
|
|
34
|
+
Initialize stack manager.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
table_name: Name of the DynamoDB table
|
|
38
|
+
region: AWS region (default: use boto3 defaults)
|
|
39
|
+
endpoint_url: Optional CloudFormation endpoint URL
|
|
40
|
+
"""
|
|
41
|
+
self.table_name = table_name
|
|
42
|
+
self.region = region
|
|
43
|
+
self.endpoint_url = endpoint_url
|
|
44
|
+
self._is_local = endpoint_url is not None
|
|
45
|
+
self._session: aioboto3.Session | None = None
|
|
46
|
+
self._client: Any = None
|
|
47
|
+
|
|
48
|
+
def _should_use_cloudformation(self) -> bool:
|
|
49
|
+
"""
|
|
50
|
+
Determine if CloudFormation should be used.
|
|
51
|
+
|
|
52
|
+
Returns False for local DynamoDB environments (endpoint_url is set).
|
|
53
|
+
"""
|
|
54
|
+
return not self._is_local
|
|
55
|
+
|
|
56
|
+
def get_stack_name(self, table_name: str | None = None) -> str:
|
|
57
|
+
"""
|
|
58
|
+
Generate stack name from table name.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
table_name: Table name (default: use self.table_name)
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
CloudFormation stack name
|
|
65
|
+
"""
|
|
66
|
+
name = table_name or self.table_name
|
|
67
|
+
return f"zae-limiter-{name}"
|
|
68
|
+
|
|
69
|
+
async def _get_client(self) -> Any:
|
|
70
|
+
"""Get or create CloudFormation client."""
|
|
71
|
+
if self._client is not None:
|
|
72
|
+
return self._client
|
|
73
|
+
|
|
74
|
+
if self._session is None:
|
|
75
|
+
self._session = aioboto3.Session()
|
|
76
|
+
|
|
77
|
+
kwargs: dict[str, Any] = {}
|
|
78
|
+
if self.region:
|
|
79
|
+
kwargs["region_name"] = self.region
|
|
80
|
+
if self.endpoint_url:
|
|
81
|
+
kwargs["endpoint_url"] = self.endpoint_url
|
|
82
|
+
|
|
83
|
+
# Type checker doesn't know _session is not None after the check above
|
|
84
|
+
session = self._session
|
|
85
|
+
self._client = await session.client("cloudformation", **kwargs).__aenter__()
|
|
86
|
+
return self._client
|
|
87
|
+
|
|
88
|
+
def _load_template(self) -> str:
|
|
89
|
+
"""Load CloudFormation template from package resources."""
|
|
90
|
+
try:
|
|
91
|
+
# Python 3.9+ importlib.resources API
|
|
92
|
+
template_data = files("zae_limiter.infra").joinpath("cfn_template.yaml").read_text()
|
|
93
|
+
return template_data
|
|
94
|
+
except Exception as e:
|
|
95
|
+
raise StackCreationError(
|
|
96
|
+
stack_name="unknown",
|
|
97
|
+
reason=f"Failed to load CloudFormation template: {e}",
|
|
98
|
+
) from e
|
|
99
|
+
|
|
100
|
+
def _format_parameters(self, parameters: dict[str, str] | None) -> list[dict[str, str]]:
|
|
101
|
+
"""
|
|
102
|
+
Convert parameter dict to CloudFormation format.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
parameters: Dict of parameter key-value pairs
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
List of CloudFormation parameter dicts
|
|
109
|
+
"""
|
|
110
|
+
# Get schema version
|
|
111
|
+
try:
|
|
112
|
+
from ..version import get_schema_version
|
|
113
|
+
|
|
114
|
+
schema_version = get_schema_version()
|
|
115
|
+
except ImportError:
|
|
116
|
+
schema_version = "1.0.0"
|
|
117
|
+
|
|
118
|
+
if not parameters:
|
|
119
|
+
# Use defaults from template with schema version
|
|
120
|
+
return [
|
|
121
|
+
{"ParameterKey": "TableName", "ParameterValue": self.table_name},
|
|
122
|
+
{"ParameterKey": "SchemaVersion", "ParameterValue": schema_version},
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
result = []
|
|
126
|
+
# Always include TableName and SchemaVersion
|
|
127
|
+
result.append({"ParameterKey": "TableName", "ParameterValue": self.table_name})
|
|
128
|
+
result.append({"ParameterKey": "SchemaVersion", "ParameterValue": schema_version})
|
|
129
|
+
|
|
130
|
+
# Map common parameter names
|
|
131
|
+
param_mapping = {
|
|
132
|
+
"snapshot_windows": "SnapshotWindows",
|
|
133
|
+
"retention_days": "SnapshotRetentionDays",
|
|
134
|
+
"lambda_memory_size": "LambdaMemorySize",
|
|
135
|
+
"lambda_timeout": "LambdaTimeout",
|
|
136
|
+
"enable_aggregator": "EnableAggregator",
|
|
137
|
+
"schema_version": "SchemaVersion",
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for key, value in parameters.items():
|
|
141
|
+
# Try mapped name first, fallback to key as-is
|
|
142
|
+
param_key = param_mapping.get(key, key)
|
|
143
|
+
result.append({"ParameterKey": param_key, "ParameterValue": str(value)})
|
|
144
|
+
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
def _get_version_tags(self) -> list[dict[str, str]]:
|
|
148
|
+
"""
|
|
149
|
+
Get version tags for CloudFormation stack.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of CloudFormation tag dicts
|
|
153
|
+
"""
|
|
154
|
+
from .. import __version__
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
from ..version import get_schema_version
|
|
158
|
+
|
|
159
|
+
schema_version = get_schema_version()
|
|
160
|
+
except ImportError:
|
|
161
|
+
schema_version = "1.0.0"
|
|
162
|
+
|
|
163
|
+
return [
|
|
164
|
+
{"Key": VERSION_TAG_KEY, "Value": __version__},
|
|
165
|
+
{"Key": SCHEMA_VERSION_TAG_KEY, "Value": schema_version},
|
|
166
|
+
{"Key": LAMBDA_VERSION_TAG_KEY, "Value": __version__},
|
|
167
|
+
]
|
|
168
|
+
|
|
169
|
+
async def stack_exists(self, stack_name: str) -> bool:
|
|
170
|
+
"""
|
|
171
|
+
Check if a CloudFormation stack exists.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
stack_name: Name of the stack
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
True if stack exists and is not in DELETE_COMPLETE state
|
|
178
|
+
"""
|
|
179
|
+
if not self._should_use_cloudformation():
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
client = await self._get_client()
|
|
183
|
+
try:
|
|
184
|
+
response = await client.describe_stacks(StackName=stack_name)
|
|
185
|
+
stacks = response.get("Stacks", [])
|
|
186
|
+
if not stacks:
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# Stack exists if it's not in DELETE_COMPLETE state
|
|
190
|
+
status = cast(str, stacks[0]["StackStatus"])
|
|
191
|
+
return status != "DELETE_COMPLETE"
|
|
192
|
+
except ClientError as e:
|
|
193
|
+
if e.response["Error"]["Code"] == "ValidationError":
|
|
194
|
+
# Stack doesn't exist
|
|
195
|
+
return False
|
|
196
|
+
raise
|
|
197
|
+
|
|
198
|
+
async def get_stack_status(self, stack_name: str) -> str | None:
|
|
199
|
+
"""
|
|
200
|
+
Get current status of a CloudFormation stack.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
stack_name: Name of the stack
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Stack status string or None if stack doesn't exist
|
|
207
|
+
"""
|
|
208
|
+
if not self._should_use_cloudformation():
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
client = await self._get_client()
|
|
212
|
+
try:
|
|
213
|
+
response = await client.describe_stacks(StackName=stack_name)
|
|
214
|
+
stacks = response.get("Stacks", [])
|
|
215
|
+
if not stacks:
|
|
216
|
+
return None
|
|
217
|
+
return cast(str, stacks[0]["StackStatus"])
|
|
218
|
+
except ClientError as e:
|
|
219
|
+
if e.response["Error"]["Code"] == "ValidationError":
|
|
220
|
+
return None
|
|
221
|
+
raise
|
|
222
|
+
|
|
223
|
+
async def create_stack(
|
|
224
|
+
self,
|
|
225
|
+
stack_name: str | None = None,
|
|
226
|
+
parameters: dict[str, str] | None = None,
|
|
227
|
+
wait: bool = True,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
"""
|
|
230
|
+
Create CloudFormation stack.
|
|
231
|
+
|
|
232
|
+
Auto-skips for local DynamoDB environments. Handles stack already
|
|
233
|
+
exists gracefully.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
stack_name: Override stack name (default: auto-generated)
|
|
237
|
+
parameters: Stack parameters dict (keys: snake_case or PascalCase)
|
|
238
|
+
wait: Wait for stack to be CREATE_COMPLETE
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Dict with stack_id, stack_name, and status
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
StackCreationError: If stack creation fails
|
|
245
|
+
StackAlreadyExistsError: If stack already exists
|
|
246
|
+
"""
|
|
247
|
+
if not self._should_use_cloudformation():
|
|
248
|
+
# Local environment - skip CloudFormation
|
|
249
|
+
return {
|
|
250
|
+
"stack_id": None,
|
|
251
|
+
"stack_name": None,
|
|
252
|
+
"status": "skipped_local",
|
|
253
|
+
"message": "CloudFormation skipped for local DynamoDB",
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
stack_name = stack_name or self.get_stack_name()
|
|
257
|
+
client = await self._get_client()
|
|
258
|
+
|
|
259
|
+
# Check if stack already exists
|
|
260
|
+
existing_status = await self.get_stack_status(stack_name)
|
|
261
|
+
if existing_status:
|
|
262
|
+
if wait and existing_status in ("CREATE_IN_PROGRESS", "UPDATE_IN_PROGRESS"):
|
|
263
|
+
# Wait for in-progress operation
|
|
264
|
+
waiter = client.get_waiter("stack_create_complete")
|
|
265
|
+
try:
|
|
266
|
+
await waiter.wait(StackName=stack_name)
|
|
267
|
+
except Exception as e:
|
|
268
|
+
raise StackCreationError(
|
|
269
|
+
stack_name=stack_name,
|
|
270
|
+
reason=f"Waiting for existing stack failed: {e}",
|
|
271
|
+
) from e
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
"stack_id": stack_name,
|
|
275
|
+
"stack_name": stack_name,
|
|
276
|
+
"status": "already_exists_and_ready",
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
# Stack exists and is stable
|
|
280
|
+
return {
|
|
281
|
+
"stack_id": stack_name,
|
|
282
|
+
"stack_name": stack_name,
|
|
283
|
+
"status": existing_status,
|
|
284
|
+
"message": f"Stack already exists with status: {existing_status}",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# Load template and format parameters
|
|
288
|
+
template_body = self._load_template()
|
|
289
|
+
cfn_parameters = self._format_parameters(parameters)
|
|
290
|
+
tags = self._get_version_tags()
|
|
291
|
+
|
|
292
|
+
# Create stack
|
|
293
|
+
try:
|
|
294
|
+
response = await client.create_stack(
|
|
295
|
+
StackName=stack_name,
|
|
296
|
+
TemplateBody=template_body,
|
|
297
|
+
Parameters=cfn_parameters,
|
|
298
|
+
Capabilities=["CAPABILITY_NAMED_IAM"],
|
|
299
|
+
Tags=tags,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
stack_id = response["StackId"]
|
|
303
|
+
|
|
304
|
+
if wait:
|
|
305
|
+
# Wait for stack creation to complete
|
|
306
|
+
waiter = client.get_waiter("stack_create_complete")
|
|
307
|
+
try:
|
|
308
|
+
await waiter.wait(StackName=stack_name)
|
|
309
|
+
except Exception as e:
|
|
310
|
+
# Fetch stack events for debugging
|
|
311
|
+
events = await self._get_stack_events(client, stack_name)
|
|
312
|
+
raise StackCreationError(
|
|
313
|
+
stack_name=stack_name,
|
|
314
|
+
reason=f"Stack creation failed: {e}",
|
|
315
|
+
events=events,
|
|
316
|
+
) from e
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
"stack_id": stack_id,
|
|
320
|
+
"stack_name": stack_name,
|
|
321
|
+
"status": "CREATE_COMPLETE" if wait else "CREATE_IN_PROGRESS",
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
except ClientError as e:
|
|
325
|
+
error_code = e.response["Error"]["Code"]
|
|
326
|
+
|
|
327
|
+
if error_code == "AlreadyExistsException":
|
|
328
|
+
# Race condition - stack was just created
|
|
329
|
+
if wait:
|
|
330
|
+
waiter = client.get_waiter("stack_create_complete")
|
|
331
|
+
try:
|
|
332
|
+
await waiter.wait(StackName=stack_name)
|
|
333
|
+
except Exception:
|
|
334
|
+
pass # Best effort
|
|
335
|
+
|
|
336
|
+
raise StackAlreadyExistsError(
|
|
337
|
+
stack_name=stack_name,
|
|
338
|
+
reason="Stack already exists",
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
# Other error
|
|
342
|
+
raise StackCreationError(
|
|
343
|
+
stack_name=stack_name,
|
|
344
|
+
reason=f"CloudFormation API error: {e.response['Error']['Message']}",
|
|
345
|
+
) from e
|
|
346
|
+
|
|
347
|
+
async def delete_stack(self, stack_name: str, wait: bool = True) -> None:
|
|
348
|
+
"""
|
|
349
|
+
Delete CloudFormation stack.
|
|
350
|
+
|
|
351
|
+
Auto-skips for local DynamoDB environments.
|
|
352
|
+
|
|
353
|
+
Args:
|
|
354
|
+
stack_name: Name of the stack to delete
|
|
355
|
+
wait: Wait for stack to be DELETE_COMPLETE
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
StackCreationError: If deletion fails
|
|
359
|
+
"""
|
|
360
|
+
if not self._should_use_cloudformation():
|
|
361
|
+
# Local environment - skip CloudFormation
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
client = await self._get_client()
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
await client.delete_stack(StackName=stack_name)
|
|
368
|
+
|
|
369
|
+
if wait:
|
|
370
|
+
waiter = client.get_waiter("stack_delete_complete")
|
|
371
|
+
await waiter.wait(StackName=stack_name)
|
|
372
|
+
|
|
373
|
+
except ClientError as e:
|
|
374
|
+
error_code = e.response["Error"]["Code"]
|
|
375
|
+
|
|
376
|
+
# Ignore if stack doesn't exist
|
|
377
|
+
if error_code == "ValidationError" and "does not exist" in str(e):
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
raise StackCreationError(
|
|
381
|
+
stack_name=stack_name,
|
|
382
|
+
reason=f"Stack deletion failed: {e.response['Error']['Message']}",
|
|
383
|
+
) from e
|
|
384
|
+
|
|
385
|
+
async def _get_stack_events(
|
|
386
|
+
self, client: Any, stack_name: str, limit: int = 20
|
|
387
|
+
) -> list[dict[str, Any]]:
|
|
388
|
+
"""
|
|
389
|
+
Fetch recent stack events for debugging.
|
|
390
|
+
|
|
391
|
+
Args:
|
|
392
|
+
client: CloudFormation client
|
|
393
|
+
stack_name: Stack name
|
|
394
|
+
limit: Max number of events to fetch
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
List of stack event dicts
|
|
398
|
+
"""
|
|
399
|
+
try:
|
|
400
|
+
response = await client.describe_stack_events(StackName=stack_name)
|
|
401
|
+
events = response.get("StackEvents", [])[:limit]
|
|
402
|
+
|
|
403
|
+
return [
|
|
404
|
+
{
|
|
405
|
+
"timestamp": e.get("Timestamp"),
|
|
406
|
+
"resource_type": e.get("ResourceType"),
|
|
407
|
+
"logical_id": e.get("LogicalResourceId"),
|
|
408
|
+
"status": e.get("ResourceStatus"),
|
|
409
|
+
"reason": e.get("ResourceStatusReason"),
|
|
410
|
+
}
|
|
411
|
+
for e in events
|
|
412
|
+
]
|
|
413
|
+
except Exception:
|
|
414
|
+
return []
|
|
415
|
+
|
|
416
|
+
async def deploy_lambda_code(
|
|
417
|
+
self,
|
|
418
|
+
function_name: str | None = None,
|
|
419
|
+
wait: bool = True,
|
|
420
|
+
) -> dict[str, Any]:
|
|
421
|
+
"""
|
|
422
|
+
Deploy Lambda function code after stack creation.
|
|
423
|
+
|
|
424
|
+
Builds the Lambda deployment package from the installed zae_limiter
|
|
425
|
+
package and updates the Lambda function code via the AWS API.
|
|
426
|
+
|
|
427
|
+
This is called after CloudFormation stack creation to replace the
|
|
428
|
+
placeholder code with the actual aggregator implementation.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
function_name: Lambda function name (default: {table_name}-aggregator)
|
|
432
|
+
wait: Wait for function update to complete
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
Dict with function_arn, code_sha256, and status
|
|
436
|
+
|
|
437
|
+
Raises:
|
|
438
|
+
StackCreationError: If Lambda deployment fails
|
|
439
|
+
"""
|
|
440
|
+
if not self._should_use_cloudformation():
|
|
441
|
+
# Local environment - no Lambda to deploy
|
|
442
|
+
return {
|
|
443
|
+
"function_arn": None,
|
|
444
|
+
"status": "skipped_local",
|
|
445
|
+
"message": "Lambda deployment skipped for local DynamoDB",
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function_name = function_name or f"{self.table_name}-aggregator"
|
|
449
|
+
|
|
450
|
+
# Build Lambda package
|
|
451
|
+
try:
|
|
452
|
+
zip_bytes = build_lambda_package()
|
|
453
|
+
except Exception as e:
|
|
454
|
+
raise StackCreationError(
|
|
455
|
+
stack_name=self.get_stack_name(),
|
|
456
|
+
reason=f"Failed to build Lambda package: {e}",
|
|
457
|
+
) from e
|
|
458
|
+
|
|
459
|
+
# Get Lambda client
|
|
460
|
+
if self._session is None:
|
|
461
|
+
self._session = aioboto3.Session()
|
|
462
|
+
|
|
463
|
+
kwargs: dict[str, Any] = {}
|
|
464
|
+
if self.region:
|
|
465
|
+
kwargs["region_name"] = self.region
|
|
466
|
+
|
|
467
|
+
session = self._session
|
|
468
|
+
async with session.client("lambda", **kwargs) as lambda_client:
|
|
469
|
+
try:
|
|
470
|
+
# Update function code
|
|
471
|
+
response = await lambda_client.update_function_code(
|
|
472
|
+
FunctionName=function_name,
|
|
473
|
+
ZipFile=zip_bytes,
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
if wait:
|
|
477
|
+
# Wait for update to complete
|
|
478
|
+
waiter = lambda_client.get_waiter("function_updated")
|
|
479
|
+
try:
|
|
480
|
+
await waiter.wait(FunctionName=function_name)
|
|
481
|
+
except Exception as e:
|
|
482
|
+
raise StackCreationError(
|
|
483
|
+
stack_name=self.get_stack_name(),
|
|
484
|
+
reason=f"Waiting for Lambda update failed: {e}",
|
|
485
|
+
) from e
|
|
486
|
+
|
|
487
|
+
# Update Lambda tags to reflect new version
|
|
488
|
+
from .. import __version__
|
|
489
|
+
|
|
490
|
+
await lambda_client.tag_resource(
|
|
491
|
+
Resource=response["FunctionArn"],
|
|
492
|
+
Tags={
|
|
493
|
+
"zae-limiter:lambda-version": __version__,
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
"function_arn": response["FunctionArn"],
|
|
499
|
+
"code_sha256": response["CodeSha256"],
|
|
500
|
+
"status": "deployed",
|
|
501
|
+
"size_bytes": len(zip_bytes),
|
|
502
|
+
"version": __version__,
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
except ClientError as e:
|
|
506
|
+
error_code = e.response["Error"]["Code"]
|
|
507
|
+
error_msg = e.response["Error"]["Message"]
|
|
508
|
+
|
|
509
|
+
raise StackCreationError(
|
|
510
|
+
stack_name=self.get_stack_name(),
|
|
511
|
+
reason=f"Lambda deployment failed ({error_code}): {error_msg}",
|
|
512
|
+
) from e
|
|
513
|
+
|
|
514
|
+
async def close(self) -> None:
|
|
515
|
+
"""Close the underlying session and client."""
|
|
516
|
+
if self._client is not None:
|
|
517
|
+
try:
|
|
518
|
+
await self._client.__aexit__(None, None, None)
|
|
519
|
+
except Exception:
|
|
520
|
+
pass # Best effort cleanup
|
|
521
|
+
finally:
|
|
522
|
+
self._client = None
|
|
523
|
+
self._session = None
|
|
524
|
+
|
|
525
|
+
async def __aenter__(self) -> "StackManager":
|
|
526
|
+
"""Enter async context manager."""
|
|
527
|
+
return self
|
|
528
|
+
|
|
529
|
+
async def __aexit__(
|
|
530
|
+
self,
|
|
531
|
+
exc_type: type[BaseException] | None,
|
|
532
|
+
exc_val: BaseException | None,
|
|
533
|
+
exc_tb: Any,
|
|
534
|
+
) -> None:
|
|
535
|
+
"""Exit async context manager."""
|
|
536
|
+
await self.close()
|