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.
@@ -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()