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/cli.py ADDED
@@ -0,0 +1,608 @@
1
+ """Command-line interface for zae-limiter infrastructure management."""
2
+
3
+ import asyncio
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from .infra.lambda_builder import get_package_info, write_lambda_package
10
+ from .infra.stack_manager import StackManager
11
+
12
+
13
+ @click.group()
14
+ @click.version_option()
15
+ def cli() -> None:
16
+ """zae-limiter infrastructure management CLI."""
17
+ pass
18
+
19
+
20
+ @cli.command()
21
+ @click.option(
22
+ "--table-name",
23
+ default="rate_limits",
24
+ help="DynamoDB table name",
25
+ )
26
+ @click.option(
27
+ "--stack-name",
28
+ help="CloudFormation stack name (default: zae-limiter-{table-name})",
29
+ )
30
+ @click.option(
31
+ "--region",
32
+ help="AWS region (default: use boto3 defaults)",
33
+ )
34
+ @click.option(
35
+ "--snapshot-windows",
36
+ default="hourly,daily",
37
+ help="Comma-separated list of snapshot windows",
38
+ )
39
+ @click.option(
40
+ "--retention-days",
41
+ default=90,
42
+ type=int,
43
+ help="Number of days to retain usage snapshots",
44
+ )
45
+ @click.option(
46
+ "--enable-aggregator/--no-aggregator",
47
+ default=True,
48
+ help="Deploy Lambda aggregator for usage snapshots",
49
+ )
50
+ @click.option(
51
+ "--wait/--no-wait",
52
+ default=True,
53
+ help="Wait for stack creation to complete",
54
+ )
55
+ def deploy(
56
+ table_name: str,
57
+ stack_name: str | None,
58
+ region: str | None,
59
+ snapshot_windows: str,
60
+ retention_days: int,
61
+ enable_aggregator: bool,
62
+ wait: bool,
63
+ ) -> None:
64
+ """Deploy CloudFormation stack with DynamoDB table and Lambda aggregator."""
65
+
66
+ async def _deploy() -> None:
67
+ async with StackManager(table_name, region, None) as manager:
68
+ actual_stack_name = stack_name or manager.get_stack_name()
69
+
70
+ click.echo(f"Deploying stack: {actual_stack_name}")
71
+ click.echo(f" Table name: {table_name}")
72
+ click.echo(f" Region: {region or 'default'}")
73
+ click.echo(f" Snapshot windows: {snapshot_windows}")
74
+ click.echo(f" Retention days: {retention_days}")
75
+ click.echo(f" Aggregator: {'enabled' if enable_aggregator else 'disabled'}")
76
+ click.echo()
77
+
78
+ parameters = {
79
+ "snapshot_windows": snapshot_windows,
80
+ "retention_days": str(retention_days),
81
+ "enable_aggregator": "true" if enable_aggregator else "false",
82
+ }
83
+
84
+ try:
85
+ # Step 1: Create CloudFormation stack
86
+ result = await manager.create_stack(
87
+ stack_name=actual_stack_name,
88
+ parameters=parameters,
89
+ wait=wait,
90
+ )
91
+
92
+ status = result.get("status", "unknown")
93
+ if status == "skipped_local":
94
+ click.echo("⚠️ CloudFormation deployment skipped (local DynamoDB detected)")
95
+ sys.exit(0)
96
+
97
+ click.echo(f"✓ Stack {status.lower().replace('_', ' ')}")
98
+
99
+ if result.get("stack_id"):
100
+ click.echo(f" Stack ID: {result['stack_id']}")
101
+
102
+ # Step 2: Deploy Lambda code if aggregator is enabled
103
+ if enable_aggregator and wait:
104
+ click.echo()
105
+ click.echo("Deploying Lambda function code...")
106
+
107
+ try:
108
+ lambda_result = await manager.deploy_lambda_code(wait=True)
109
+
110
+ if lambda_result.get("status") == "deployed":
111
+ size_kb = lambda_result.get("size_bytes", 0) / 1024
112
+ click.echo(f"✓ Lambda code deployed ({size_kb:.1f} KB)")
113
+ click.echo(f" Function ARN: {lambda_result['function_arn']}")
114
+ click.echo(f" Code SHA256: {lambda_result['code_sha256'][:16]}...")
115
+ elif lambda_result.get("status") == "skipped_local":
116
+ click.echo(" Lambda deployment skipped (local environment)")
117
+ except Exception as e:
118
+ click.echo(f"⚠️ Lambda deployment failed: {e}", err=True)
119
+ click.echo(
120
+ " Stack was created successfully, but Lambda code "
121
+ "needs manual deployment.",
122
+ err=True,
123
+ )
124
+ sys.exit(1)
125
+
126
+ if not wait:
127
+ click.echo()
128
+ click.echo("Stack creation initiated. Use 'status' command to check progress.")
129
+ if enable_aggregator:
130
+ click.echo(
131
+ "Note: Lambda code will not be deployed until stack is ready. "
132
+ "Run 'zae-limiter deploy' again with --wait to deploy Lambda."
133
+ )
134
+
135
+ except Exception as e:
136
+ click.echo(f"✗ Deployment failed: {e}", err=True)
137
+ sys.exit(1)
138
+
139
+ asyncio.run(_deploy())
140
+
141
+
142
+ @cli.command()
143
+ @click.option(
144
+ "--stack-name",
145
+ required=True,
146
+ help="CloudFormation stack name to delete",
147
+ )
148
+ @click.option(
149
+ "--region",
150
+ help="AWS region (default: use boto3 defaults)",
151
+ )
152
+ @click.option(
153
+ "--wait/--no-wait",
154
+ default=True,
155
+ help="Wait for stack deletion to complete",
156
+ )
157
+ @click.option(
158
+ "--yes",
159
+ "-y",
160
+ is_flag=True,
161
+ help="Skip confirmation prompt",
162
+ )
163
+ def delete(
164
+ stack_name: str,
165
+ region: str | None,
166
+ wait: bool,
167
+ yes: bool,
168
+ ) -> None:
169
+ """Delete CloudFormation stack."""
170
+
171
+ if not yes:
172
+ click.confirm(
173
+ f"Are you sure you want to delete stack '{stack_name}'?",
174
+ abort=True,
175
+ )
176
+
177
+ async def _delete() -> None:
178
+ async with StackManager("dummy", region, None) as manager:
179
+ click.echo(f"Deleting stack: {stack_name}")
180
+
181
+ try:
182
+ await manager.delete_stack(stack_name=stack_name, wait=wait)
183
+
184
+ if wait:
185
+ click.echo(f"✓ Stack '{stack_name}' deleted successfully")
186
+ else:
187
+ click.echo("Stack deletion initiated. Use 'status' command to check progress.")
188
+
189
+ except Exception as e:
190
+ click.echo(f"✗ Deletion failed: {e}", err=True)
191
+ sys.exit(1)
192
+
193
+ asyncio.run(_delete())
194
+
195
+
196
+ @cli.command()
197
+ @click.option(
198
+ "--output",
199
+ "-o",
200
+ type=click.Path(),
201
+ help="Output file (default: stdout)",
202
+ )
203
+ def cfn_template(output: str | None) -> None:
204
+ """Export CloudFormation template for custom deployment."""
205
+ try:
206
+ template_path = Path(__file__).parent / "infra" / "cfn_template.yaml"
207
+
208
+ if not template_path.exists():
209
+ click.echo(f"✗ Template not found: {template_path}", err=True)
210
+ sys.exit(1)
211
+
212
+ content = template_path.read_text()
213
+
214
+ if output:
215
+ output_path = Path(output)
216
+ output_path.write_text(content)
217
+ click.echo(f"✓ Template exported to: {output}")
218
+ else:
219
+ click.echo(content)
220
+
221
+ except Exception as e:
222
+ click.echo(f"✗ Failed to export template: {e}", err=True)
223
+ sys.exit(1)
224
+
225
+
226
+ @cli.command("lambda-export")
227
+ @click.option(
228
+ "--output",
229
+ "-o",
230
+ type=click.Path(),
231
+ default="lambda.zip",
232
+ help="Output file path (default: lambda.zip)",
233
+ )
234
+ @click.option(
235
+ "--info",
236
+ is_flag=True,
237
+ help="Show package information without building",
238
+ )
239
+ @click.option(
240
+ "--force",
241
+ "-f",
242
+ is_flag=True,
243
+ help="Overwrite existing file without prompting",
244
+ )
245
+ def lambda_export(output: str, info: bool, force: bool) -> None:
246
+ """Export Lambda deployment package for custom deployment."""
247
+ try:
248
+ if info:
249
+ # Show package info without building
250
+ pkg_info = get_package_info()
251
+ click.echo()
252
+ click.echo("Lambda Package Information")
253
+ click.echo("=" * 26)
254
+ click.echo()
255
+ click.echo(f"Package path: {pkg_info['package_path']}")
256
+ click.echo(f"Python files: {pkg_info['python_files']}")
257
+ click.echo(f"Uncompressed size: {int(pkg_info['uncompressed_size']) / 1024:.1f} KB")
258
+ click.echo(f"Handler: {pkg_info['handler']}")
259
+ click.echo()
260
+ return
261
+
262
+ output_path = Path(output)
263
+
264
+ # Check if file exists
265
+ if output_path.exists() and not force:
266
+ click.echo(f"File already exists: {output_path}", err=True)
267
+ click.echo("Use --force to overwrite.", err=True)
268
+ sys.exit(1)
269
+
270
+ # Build and write the package
271
+ size_bytes = write_lambda_package(output_path)
272
+ size_kb = size_bytes / 1024
273
+
274
+ click.echo(f"✓ Exported Lambda package to: {output_path} ({size_kb:.1f} KB)")
275
+
276
+ except Exception as e:
277
+ click.echo(f"✗ Failed to export Lambda package: {e}", err=True)
278
+ sys.exit(1)
279
+
280
+
281
+ @cli.command()
282
+ @click.option(
283
+ "--stack-name",
284
+ required=True,
285
+ help="CloudFormation stack name",
286
+ )
287
+ @click.option(
288
+ "--region",
289
+ help="AWS region (default: use boto3 defaults)",
290
+ )
291
+ def status(stack_name: str, region: str | None) -> None:
292
+ """Get CloudFormation stack status."""
293
+
294
+ async def _status() -> None:
295
+ async with StackManager("dummy", region, None) as manager:
296
+ try:
297
+ stack_status = await manager.get_stack_status(stack_name)
298
+
299
+ if stack_status is None:
300
+ click.echo(f"Stack '{stack_name}' not found")
301
+ sys.exit(1)
302
+
303
+ click.echo(f"Stack: {stack_name}")
304
+ click.echo(f"Status: {stack_status}")
305
+
306
+ # Interpret status
307
+ if stack_status == "CREATE_COMPLETE":
308
+ click.echo("✓ Stack is ready")
309
+ elif stack_status == "DELETE_COMPLETE":
310
+ click.echo("✓ Stack has been deleted")
311
+ elif "IN_PROGRESS" in stack_status:
312
+ click.echo("⏳ Operation in progress...")
313
+ elif "FAILED" in stack_status or "ROLLBACK" in stack_status:
314
+ click.echo("✗ Stack operation failed", err=True)
315
+ sys.exit(1)
316
+
317
+ except Exception as e:
318
+ click.echo(f"✗ Failed to get status: {e}", err=True)
319
+ sys.exit(1)
320
+
321
+ asyncio.run(_status())
322
+
323
+
324
+ @cli.command("version")
325
+ @click.option(
326
+ "--table-name",
327
+ required=True,
328
+ help="DynamoDB table name",
329
+ )
330
+ @click.option(
331
+ "--region",
332
+ help="AWS region (default: use boto3 defaults)",
333
+ )
334
+ @click.option(
335
+ "--stack-name",
336
+ help="CloudFormation stack name (default: zae-limiter-{table-name})",
337
+ )
338
+ def version_cmd(
339
+ table_name: str,
340
+ region: str | None,
341
+ stack_name: str | None,
342
+ ) -> None:
343
+ """Show infrastructure version information."""
344
+ from . import __version__
345
+ from .version import (
346
+ InfrastructureVersion,
347
+ check_compatibility,
348
+ get_schema_version,
349
+ )
350
+
351
+ async def _version() -> None:
352
+ # Import here to avoid loading aioboto3 at CLI startup
353
+ from .repository import Repository
354
+
355
+ repo = Repository(table_name, region, None)
356
+
357
+ try:
358
+ click.echo()
359
+ click.echo("zae-limiter Infrastructure Version")
360
+ click.echo("=" * 36)
361
+ click.echo()
362
+ click.echo(f"Client Version: {__version__}")
363
+ click.echo(f"Schema Version: {get_schema_version()}")
364
+ click.echo()
365
+
366
+ # Get version from DynamoDB
367
+ version_record = await repo.get_version_record()
368
+
369
+ if version_record is None:
370
+ click.echo("Infrastructure: Not initialized")
371
+ click.echo()
372
+ click.echo("Run 'zae-limiter deploy' to initialize infrastructure.")
373
+ return
374
+
375
+ infra_version = InfrastructureVersion.from_record(version_record)
376
+
377
+ click.echo(f"Infra Schema: {infra_version.schema_version}")
378
+ click.echo(f"Lambda Version: {infra_version.lambda_version or 'unknown'}")
379
+ click.echo(f"Min Client Version: {infra_version.client_min_version}")
380
+ click.echo()
381
+
382
+ # Check compatibility
383
+ compat = check_compatibility(__version__, infra_version)
384
+
385
+ if compat.is_compatible and not compat.requires_lambda_update:
386
+ click.echo("Status: COMPATIBLE")
387
+ elif compat.requires_lambda_update:
388
+ click.echo("Status: COMPATIBLE (Lambda update available)")
389
+ click.echo()
390
+ click.echo(f" {compat.message}")
391
+ click.echo()
392
+ click.echo("Run 'zae-limiter upgrade' to update Lambda.")
393
+ elif compat.requires_schema_migration:
394
+ click.echo("Status: INCOMPATIBLE (Schema migration required)", err=True)
395
+ click.echo()
396
+ click.echo(f" {compat.message}")
397
+ sys.exit(1)
398
+ else:
399
+ click.echo("Status: INCOMPATIBLE", err=True)
400
+ click.echo()
401
+ click.echo(f" {compat.message}")
402
+ sys.exit(1)
403
+
404
+ except Exception as e:
405
+ click.echo(f"✗ Failed to get version info: {e}", err=True)
406
+ sys.exit(1)
407
+ finally:
408
+ await repo.close()
409
+
410
+ asyncio.run(_version())
411
+
412
+
413
+ @cli.command()
414
+ @click.option(
415
+ "--table-name",
416
+ required=True,
417
+ help="DynamoDB table name",
418
+ )
419
+ @click.option(
420
+ "--region",
421
+ help="AWS region (default: use boto3 defaults)",
422
+ )
423
+ @click.option(
424
+ "--stack-name",
425
+ help="CloudFormation stack name (default: zae-limiter-{table-name})",
426
+ )
427
+ @click.option(
428
+ "--lambda-only",
429
+ is_flag=True,
430
+ help="Only update Lambda code",
431
+ )
432
+ @click.option(
433
+ "--force",
434
+ is_flag=True,
435
+ help="Force update even if version matches",
436
+ )
437
+ def upgrade(
438
+ table_name: str,
439
+ region: str | None,
440
+ stack_name: str | None,
441
+ lambda_only: bool,
442
+ force: bool,
443
+ ) -> None:
444
+ """Upgrade infrastructure to match client version."""
445
+ from . import __version__
446
+ from .version import (
447
+ InfrastructureVersion,
448
+ check_compatibility,
449
+ get_schema_version,
450
+ )
451
+
452
+ async def _upgrade() -> None:
453
+ from .repository import Repository
454
+
455
+ repo = Repository(table_name, region, None)
456
+
457
+ try:
458
+ click.echo()
459
+ click.echo("Checking infrastructure version...")
460
+
461
+ version_record = await repo.get_version_record()
462
+
463
+ if version_record is None:
464
+ click.echo("Infrastructure not initialized.")
465
+ click.echo("Run 'zae-limiter deploy' first.")
466
+ sys.exit(1)
467
+
468
+ infra_version = InfrastructureVersion.from_record(version_record)
469
+ compat = check_compatibility(__version__, infra_version)
470
+
471
+ if not force and compat.is_compatible and not compat.requires_lambda_update:
472
+ click.echo()
473
+ click.echo("Infrastructure is already up to date.")
474
+ click.echo(f" Client: {__version__}")
475
+ click.echo(f" Lambda: {infra_version.lambda_version}")
476
+ return
477
+
478
+ if compat.requires_schema_migration:
479
+ click.echo()
480
+ click.echo("✗ Schema migration required - cannot auto-upgrade", err=True)
481
+ click.echo(f" {compat.message}")
482
+ sys.exit(1)
483
+
484
+ # Perform upgrade
485
+ click.echo()
486
+ click.echo(f"Current: Lambda {infra_version.lambda_version or 'unknown'}")
487
+ click.echo(f"Target: Lambda {__version__}")
488
+ click.echo()
489
+
490
+ async with StackManager(table_name, region, None) as manager:
491
+ # Step 1: Update Lambda code
492
+ click.echo("[1/2] Deploying Lambda code...")
493
+ try:
494
+ result = await manager.deploy_lambda_code(wait=True)
495
+
496
+ if result.get("status") == "deployed":
497
+ size_kb = result.get("size_bytes", 0) / 1024
498
+ click.echo(f" Lambda code deployed ({size_kb:.1f} KB)")
499
+ elif result.get("status") == "skipped_local":
500
+ click.echo(" Skipped (local environment)")
501
+
502
+ except Exception as e:
503
+ click.echo(f"✗ Lambda deployment failed: {e}", err=True)
504
+ sys.exit(1)
505
+
506
+ # Step 2: Update version record
507
+ click.echo("[2/2] Updating version record...")
508
+ await repo.set_version_record(
509
+ schema_version=get_schema_version(),
510
+ lambda_version=__version__,
511
+ client_min_version="0.0.0",
512
+ updated_by=f"cli:{__version__}",
513
+ )
514
+ click.echo(" Version record updated")
515
+
516
+ click.echo()
517
+ click.echo("✓ Upgrade complete!")
518
+
519
+ except Exception as e:
520
+ click.echo(f"✗ Upgrade failed: {e}", err=True)
521
+ sys.exit(1)
522
+ finally:
523
+ await repo.close()
524
+
525
+ asyncio.run(_upgrade())
526
+
527
+
528
+ @cli.command()
529
+ @click.option(
530
+ "--table-name",
531
+ required=True,
532
+ help="DynamoDB table name",
533
+ )
534
+ @click.option(
535
+ "--region",
536
+ help="AWS region (default: use boto3 defaults)",
537
+ )
538
+ def check(
539
+ table_name: str,
540
+ region: str | None,
541
+ ) -> None:
542
+ """Check infrastructure compatibility without modifying."""
543
+ from . import __version__
544
+ from .version import (
545
+ InfrastructureVersion,
546
+ check_compatibility,
547
+ )
548
+
549
+ async def _check() -> None:
550
+ from .repository import Repository
551
+
552
+ repo = Repository(table_name, region, None)
553
+
554
+ try:
555
+ click.echo()
556
+ click.echo("Compatibility Check")
557
+ click.echo("=" * 20)
558
+ click.echo()
559
+
560
+ version_record = await repo.get_version_record()
561
+
562
+ if version_record is None:
563
+ click.echo("Result: NOT INITIALIZED")
564
+ click.echo()
565
+ click.echo("Infrastructure has not been deployed yet.")
566
+ click.echo("Run 'zae-limiter deploy' to initialize.")
567
+ sys.exit(1)
568
+
569
+ infra_version = InfrastructureVersion.from_record(version_record)
570
+ compat = check_compatibility(__version__, infra_version)
571
+
572
+ click.echo(f"Client: {__version__}")
573
+ click.echo(f"Schema: {infra_version.schema_version}")
574
+ click.echo(f"Lambda: {infra_version.lambda_version or 'unknown'}")
575
+ click.echo()
576
+
577
+ if compat.is_compatible and not compat.requires_lambda_update:
578
+ click.echo("Result: COMPATIBLE")
579
+ click.echo()
580
+ click.echo("Client and infrastructure are fully compatible.")
581
+ elif compat.requires_lambda_update:
582
+ click.echo("Result: COMPATIBLE (update available)")
583
+ click.echo()
584
+ click.echo(compat.message)
585
+ click.echo()
586
+ click.echo("Run 'zae-limiter upgrade' to update.")
587
+ elif compat.requires_schema_migration:
588
+ click.echo("Result: INCOMPATIBLE", err=True)
589
+ click.echo()
590
+ click.echo(compat.message)
591
+ sys.exit(1)
592
+ else:
593
+ click.echo("Result: INCOMPATIBLE", err=True)
594
+ click.echo()
595
+ click.echo(compat.message)
596
+ sys.exit(1)
597
+
598
+ except Exception as e:
599
+ click.echo(f"✗ Check failed: {e}", err=True)
600
+ sys.exit(1)
601
+ finally:
602
+ await repo.close()
603
+
604
+ asyncio.run(_check())
605
+
606
+
607
+ if __name__ == "__main__":
608
+ cli()