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