stackport 0.1.3__tar.gz → 0.1.5__tar.gz

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.
Files changed (45) hide show
  1. {stackport-0.1.3/stackport.egg-info → stackport-0.1.5}/PKG-INFO +13 -6
  2. {stackport-0.1.3 → stackport-0.1.5}/README.md +12 -5
  3. {stackport-0.1.3 → stackport-0.1.5}/backend/config.py +1 -0
  4. {stackport-0.1.3 → stackport-0.1.5}/backend/main.py +23 -5
  5. stackport-0.1.5/backend/routes/ec2.py +326 -0
  6. stackport-0.1.5/backend/routes/iam.py +298 -0
  7. stackport-0.1.5/backend/routes/lambda_svc.py +185 -0
  8. stackport-0.1.5/backend/routes/logs.py +216 -0
  9. stackport-0.1.5/backend/routes/sqs.py +306 -0
  10. {stackport-0.1.3 → stackport-0.1.5}/pyproject.toml +1 -1
  11. {stackport-0.1.3 → stackport-0.1.5/stackport.egg-info}/PKG-INFO +13 -6
  12. {stackport-0.1.3 → stackport-0.1.5}/stackport.egg-info/SOURCES.txt +16 -4
  13. stackport-0.1.5/tests/test_ec2_routes.py +298 -0
  14. stackport-0.1.5/tests/test_iam_routes.py +382 -0
  15. stackport-0.1.5/tests/test_lambda_routes.py +271 -0
  16. stackport-0.1.5/tests/test_logs_routes.py +274 -0
  17. stackport-0.1.5/tests/test_sqs_routes.py +290 -0
  18. stackport-0.1.5/ui/dist/assets/index-BFwEzJQQ.css +1 -0
  19. stackport-0.1.5/ui/dist/assets/index-DeaAXMzy.js +477 -0
  20. stackport-0.1.5/ui/dist/favicon.svg +68 -0
  21. {stackport-0.1.3 → stackport-0.1.5}/ui/dist/index.html +3 -3
  22. stackport-0.1.3/ui/dist/assets/index-CEFYqXru.css +0 -1
  23. stackport-0.1.3/ui/dist/assets/index-DyyngEdQ.js +0 -370
  24. {stackport-0.1.3 → stackport-0.1.5}/LICENSE +0 -0
  25. {stackport-0.1.3 → stackport-0.1.5}/MANIFEST.in +0 -0
  26. {stackport-0.1.3 → stackport-0.1.5}/backend/__init__.py +0 -0
  27. {stackport-0.1.3 → stackport-0.1.5}/backend/aws_client.py +0 -0
  28. {stackport-0.1.3 → stackport-0.1.5}/backend/cache.py +0 -0
  29. {stackport-0.1.3 → stackport-0.1.5}/backend/routes/__init__.py +0 -0
  30. {stackport-0.1.3 → stackport-0.1.5}/backend/routes/dynamodb.py +0 -0
  31. {stackport-0.1.3 → stackport-0.1.5}/backend/routes/resources.py +0 -0
  32. {stackport-0.1.3 → stackport-0.1.5}/backend/routes/s3.py +0 -0
  33. {stackport-0.1.3 → stackport-0.1.5}/backend/routes/stats.py +0 -0
  34. {stackport-0.1.3 → stackport-0.1.5}/setup.cfg +0 -0
  35. {stackport-0.1.3 → stackport-0.1.5}/stackport.egg-info/dependency_links.txt +0 -0
  36. {stackport-0.1.3 → stackport-0.1.5}/stackport.egg-info/entry_points.txt +0 -0
  37. {stackport-0.1.3 → stackport-0.1.5}/stackport.egg-info/requires.txt +0 -0
  38. {stackport-0.1.3 → stackport-0.1.5}/stackport.egg-info/top_level.txt +0 -0
  39. {stackport-0.1.3 → stackport-0.1.5}/tests/test_cache.py +0 -0
  40. {stackport-0.1.3 → stackport-0.1.5}/tests/test_client.py +0 -0
  41. {stackport-0.1.3 → stackport-0.1.5}/tests/test_config.py +0 -0
  42. {stackport-0.1.3 → stackport-0.1.5}/tests/test_dynamodb_routes.py +0 -0
  43. {stackport-0.1.3 → stackport-0.1.5}/tests/test_registries.py +0 -0
  44. {stackport-0.1.3 → stackport-0.1.5}/tests/test_routes.py +0 -0
  45. {stackport-0.1.3 → stackport-0.1.5}/ui/dist/favicon.png +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackport
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: Universal AWS resource browser for local emulators
5
5
  Author: Davi Reis Vieira
6
6
  License: MIT
@@ -30,17 +30,23 @@ Requires-Dist: httpx>=0.27.0; extra == "dev"
30
30
  Requires-Dist: moto[dynamodb,iam,lambda,s3,sqs]>=5.0; extra == "dev"
31
31
  Dynamic: license-file
32
32
 
33
- # StackPort
33
+ <p align="center">
34
+ <img src="docs/images/stackport_logo.svg" alt="StackPort — Universal AWS Resource Browser" width="150"/>
35
+ </p>
36
+
37
+ <h1 align="center">StackPort</h1>
38
+ <p align="center"><strong>Universal AWS resource browser for local emulators. Built to work with MiniStack, and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.</strong></p>
34
39
 
35
- <p>
40
+ <p align="center">
36
41
  <a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
37
42
  <a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
38
43
  <a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
39
- <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
44
+ <a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/image-size/davireis/stackport/latest" alt="Docker Image Size"></a>
45
+ <a href="https://github.com/DaviReisVieira/stackport/blob/master/LICENSE"><img src="https://img.shields.io/github/license/DaviReisVieira/stackport" alt="License"></a>
46
+ <img src="https://img.shields.io/badge/python-3.12-slim" alt="Python">
47
+ <a href="https://github.com/DaviReisVieira/stackport/stargazers"><img src="https://img.shields.io/github/stars/DaviReisVieira/stackport" alt="GitHub stars"></a>
40
48
  </p>
41
49
 
42
- Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
43
-
44
50
  ## Screenshots
45
51
 
46
52
  **Dashboard** — Service overview with resource counts and health status
@@ -115,6 +121,7 @@ AWS_ENDPOINT_URL=http://my-emulator:4566 stackport
115
121
  | `AWS_SECRET_ACCESS_KEY` | `test` | AWS secret key |
116
122
  | `STACKPORT_PORT` | `8080` | StackPort server port |
117
123
  | `STACKPORT_SERVICES` | *(35 services)* | Comma-separated services to probe |
124
+ | `LOG_LEVEL` | `INFO` | Python log level (DEBUG shows healthcheck logs) |
118
125
 
119
126
  ## Supported Services (35)
120
127
 
@@ -1,14 +1,20 @@
1
- # StackPort
1
+ <p align="center">
2
+ <img src="docs/images/stackport_logo.svg" alt="StackPort — Universal AWS Resource Browser" width="150"/>
3
+ </p>
4
+
5
+ <h1 align="center">StackPort</h1>
6
+ <p align="center"><strong>Universal AWS resource browser for local emulators. Built to work with MiniStack, and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.</strong></p>
2
7
 
3
- <p>
8
+ <p align="center">
4
9
  <a href="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml"><img src="https://github.com/DaviReisVieira/stackport/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
5
10
  <a href="https://pypi.org/project/stackport/"><img src="https://img.shields.io/pypi/v/stackport" alt="PyPI Version"></a>
6
11
  <a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/pulls/davireis/stackport" alt="Docker Pulls"></a>
7
- <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
12
+ <a href="https://hub.docker.com/r/davireis/stackport"><img src="https://img.shields.io/docker/image-size/davireis/stackport/latest" alt="Docker Image Size"></a>
13
+ <a href="https://github.com/DaviReisVieira/stackport/blob/master/LICENSE"><img src="https://img.shields.io/github/license/DaviReisVieira/stackport" alt="License"></a>
14
+ <img src="https://img.shields.io/badge/python-3.12-slim" alt="Python">
15
+ <a href="https://github.com/DaviReisVieira/stackport/stargazers"><img src="https://img.shields.io/github/stars/DaviReisVieira/stackport" alt="GitHub stars"></a>
8
16
  </p>
9
17
 
10
- Universal AWS resource browser for local emulators. Built to work with [**MiniStack**](https://github.com/Nahuel990/ministack), and also compatible with LocalStack, Moto, or any AWS-compatible endpoint.
11
-
12
18
  ## Screenshots
13
19
 
14
20
  **Dashboard** — Service overview with resource counts and health status
@@ -83,6 +89,7 @@ AWS_ENDPOINT_URL=http://my-emulator:4566 stackport
83
89
  | `AWS_SECRET_ACCESS_KEY` | `test` | AWS secret key |
84
90
  | `STACKPORT_PORT` | `8080` | StackPort server port |
85
91
  | `STACKPORT_SERVICES` | *(35 services)* | Comma-separated services to probe |
92
+ | `LOG_LEVEL` | `INFO` | Python log level (DEBUG shows healthcheck logs) |
86
93
 
87
94
  ## Supported Services (35)
88
95
 
@@ -12,3 +12,4 @@ STACKPORT_SERVICES: str = os.environ.get(
12
12
  "ecr,elasticache,glue,athena,apigateway,firehose,cognito-idp,cognito-identity,"
13
13
  "elasticmapreduce,elasticloadbalancing,elasticfilesystem,cloudfront,appsync",
14
14
  )
15
+ LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO").upper()
@@ -1,6 +1,5 @@
1
1
  import logging
2
2
  import os
3
- import time
4
3
 
5
4
  import uvicorn
6
5
  from fastapi import FastAPI
@@ -8,15 +7,29 @@ from fastapi.middleware.cors import CORSMiddleware
8
7
  from fastapi.responses import FileResponse
9
8
  from fastapi.staticfiles import StaticFiles
10
9
 
11
- from backend.config import STACKPORT_PORT
12
- from backend.routes import dynamodb, resources, s3, stats
10
+ from backend.config import LOG_LEVEL, STACKPORT_PORT
11
+ from backend.routes import dynamodb, ec2, iam, lambda_svc, logs, resources, s3, sqs, stats
12
+
13
+
14
+ class HealthcheckFilter(logging.Filter):
15
+ """Suppress healthcheck access logs unless LOG_LEVEL is DEBUG."""
16
+
17
+ def filter(self, record: logging.LogRecord) -> bool:
18
+ if getattr(logging, LOG_LEVEL, logging.INFO) <= logging.DEBUG:
19
+ return True
20
+ message = record.getMessage()
21
+ return "/health" not in message
22
+
13
23
 
14
24
  logging.basicConfig(
15
- level=logging.INFO,
25
+ level=LOG_LEVEL,
16
26
  format="%(asctime)s %(name)s %(levelname)s %(message)s",
17
27
  )
18
28
  logger = logging.getLogger(__name__)
19
29
 
30
+ # Suppress noisy healthcheck access logs at non-DEBUG levels
31
+ logging.getLogger("uvicorn.access").addFilter(HealthcheckFilter())
32
+
20
33
  app = FastAPI(title="StackPort", docs_url="/api/docs")
21
34
 
22
35
  app.add_middleware(
@@ -29,6 +42,11 @@ app.add_middleware(
29
42
  app.include_router(stats.router, prefix="/api")
30
43
  app.include_router(s3.router, prefix="/api/s3")
31
44
  app.include_router(dynamodb.router, prefix="/api/dynamodb")
45
+ app.include_router(lambda_svc.router, prefix="/api/lambda", tags=["lambda"])
46
+ app.include_router(sqs.router, prefix="/api/sqs", tags=["sqs"])
47
+ app.include_router(iam.router, prefix="/api/iam", tags=["iam"])
48
+ app.include_router(ec2.router, prefix="/api/ec2", tags=["ec2"])
49
+ app.include_router(logs.router, prefix="/api/logs", tags=["logs"])
32
50
  app.include_router(resources.router, prefix="/api")
33
51
 
34
52
  # Serve UI static files — mount assets under /assets, SPA fallback for everything else
@@ -47,7 +65,7 @@ if os.path.isdir(ui_dist):
47
65
 
48
66
 
49
67
  def cli():
50
- uvicorn.run("backend.main:app", host="0.0.0.0", port=STACKPORT_PORT, reload=False)
68
+ uvicorn.run("backend.main:app", host="0.0.0.0", port=STACKPORT_PORT, log_level=LOG_LEVEL.lower(), reload=False)
51
69
 
52
70
 
53
71
  if __name__ == "__main__":
@@ -0,0 +1,326 @@
1
+ """EC2 service-specific routes."""
2
+
3
+ import base64
4
+ from typing import Any
5
+
6
+ from fastapi import APIRouter, HTTPException
7
+
8
+ from backend.aws_client import get_client
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ def _get_instance_name(tags: list[dict] | None) -> str:
14
+ """Extract Name tag from instance tags."""
15
+ if not tags:
16
+ return ""
17
+ for tag in tags:
18
+ if tag.get("Key") == "Name":
19
+ return tag.get("Value", "")
20
+ return ""
21
+
22
+
23
+ def _flatten_instances(reservations: list[dict]) -> list[dict]:
24
+ """Flatten Reservations structure to flat list of instances."""
25
+ instances = []
26
+ for reservation in reservations:
27
+ instances.extend(reservation.get("Instances", []))
28
+ return instances
29
+
30
+
31
+ def _decode_user_data(encoded: str | None) -> str | None:
32
+ """Decode base64-encoded user data."""
33
+ if not encoded:
34
+ return None
35
+ try:
36
+ return base64.b64decode(encoded).decode("utf-8")
37
+ except Exception:
38
+ return None
39
+
40
+
41
+ @router.get("/instances")
42
+ def list_instances() -> dict[str, Any]:
43
+ """List all EC2 instances with enriched metadata."""
44
+ try:
45
+ client = get_client("ec2")
46
+ paginator = client.get_paginator("describe_instances")
47
+
48
+ all_instances = []
49
+ for page in paginator.paginate():
50
+ instances = _flatten_instances(page.get("Reservations", []))
51
+ for instance in instances:
52
+ all_instances.append(
53
+ {
54
+ "instanceId": instance["InstanceId"],
55
+ "name": _get_instance_name(instance.get("Tags")),
56
+ "state": instance["State"]["Name"],
57
+ "instanceType": instance["InstanceType"],
58
+ "imageId": instance.get("ImageId"),
59
+ "launchTime": instance.get("LaunchTime").isoformat() if instance.get("LaunchTime") else None,
60
+ "publicIpAddress": instance.get("PublicIpAddress"),
61
+ "privateIpAddress": instance.get("PrivateIpAddress"),
62
+ "vpcId": instance.get("VpcId"),
63
+ "subnetId": instance.get("SubnetId"),
64
+ "keyName": instance.get("KeyName"),
65
+ "platform": instance.get("Platform"),
66
+ "securityGroups": instance.get("SecurityGroups", []),
67
+ "tags": instance.get("Tags", []),
68
+ }
69
+ )
70
+
71
+ return {"instances": all_instances}
72
+ except Exception as e:
73
+ raise HTTPException(status_code=500, detail=str(e))
74
+
75
+
76
+ @router.get("/instances/{instance_id}")
77
+ def get_instance_detail(instance_id: str) -> dict[str, Any]:
78
+ """Get detailed information for a specific instance including user data."""
79
+ try:
80
+ client = get_client("ec2")
81
+
82
+ # Get instance details
83
+ response = client.describe_instances(InstanceIds=[instance_id])
84
+ reservations = response.get("Reservations", [])
85
+
86
+ if not reservations or not reservations[0].get("Instances"):
87
+ raise HTTPException(status_code=404, detail=f"Instance {instance_id} not found")
88
+
89
+ instance = reservations[0]["Instances"][0]
90
+
91
+ # Try to get user data
92
+ user_data = None
93
+ try:
94
+ user_data_response = client.describe_instance_attribute(
95
+ InstanceId=instance_id, Attribute="userData"
96
+ )
97
+ encoded_data = user_data_response.get("UserData", {}).get("Value")
98
+ user_data = _decode_user_data(encoded_data)
99
+ except Exception:
100
+ # User data may not exist or permission denied
101
+ pass
102
+
103
+ return {
104
+ "instance": {
105
+ "instanceId": instance["InstanceId"],
106
+ "name": _get_instance_name(instance.get("Tags")),
107
+ "state": instance["State"]["Name"],
108
+ "stateCode": instance["State"]["Code"],
109
+ "instanceType": instance["InstanceType"],
110
+ "imageId": instance.get("ImageId"),
111
+ "launchTime": instance.get("LaunchTime").isoformat() if instance.get("LaunchTime") else None,
112
+ "publicIpAddress": instance.get("PublicIpAddress"),
113
+ "privateIpAddress": instance.get("PrivateIpAddress"),
114
+ "vpcId": instance.get("VpcId"),
115
+ "subnetId": instance.get("SubnetId"),
116
+ "keyName": instance.get("KeyName"),
117
+ "platform": instance.get("Platform"),
118
+ "securityGroups": instance.get("SecurityGroups", []),
119
+ "networkInterfaces": instance.get("NetworkInterfaces", []),
120
+ "blockDeviceMappings": instance.get("BlockDeviceMappings", []),
121
+ "tags": instance.get("Tags", []),
122
+ "userData": user_data,
123
+ }
124
+ }
125
+ except HTTPException:
126
+ raise
127
+ except client.exceptions.ClientError as e:
128
+ error_code = e.response["Error"]["Code"]
129
+ if error_code == "InvalidInstanceID.NotFound":
130
+ raise HTTPException(status_code=404, detail=f"Instance {instance_id} not found")
131
+ raise HTTPException(status_code=500, detail=str(e))
132
+ except Exception as e:
133
+ raise HTTPException(status_code=500, detail=str(e))
134
+
135
+
136
+ @router.post("/instances/{instance_id}/start")
137
+ def start_instance(instance_id: str) -> dict[str, Any]:
138
+ """Start a stopped EC2 instance."""
139
+ try:
140
+ client = get_client("ec2")
141
+ response = client.start_instances(InstanceIds=[instance_id])
142
+
143
+ starting_instances = response.get("StartingInstances", [])
144
+ if not starting_instances:
145
+ raise HTTPException(status_code=500, detail="No state change returned")
146
+
147
+ return {
148
+ "success": True,
149
+ "state": {
150
+ "previous": starting_instances[0]["PreviousState"]["Name"],
151
+ "current": starting_instances[0]["CurrentState"]["Name"],
152
+ },
153
+ }
154
+ except client.exceptions.ClientError as e:
155
+ error_code = e.response["Error"]["Code"]
156
+ if error_code == "InvalidInstanceID.NotFound":
157
+ raise HTTPException(status_code=404, detail=f"Instance {instance_id} not found")
158
+ raise HTTPException(status_code=400, detail=str(e))
159
+ except Exception as e:
160
+ raise HTTPException(status_code=500, detail=str(e))
161
+
162
+
163
+ @router.post("/instances/{instance_id}/stop")
164
+ def stop_instance(instance_id: str) -> dict[str, Any]:
165
+ """Stop a running EC2 instance."""
166
+ try:
167
+ client = get_client("ec2")
168
+ response = client.stop_instances(InstanceIds=[instance_id])
169
+
170
+ stopping_instances = response.get("StoppingInstances", [])
171
+ if not stopping_instances:
172
+ raise HTTPException(status_code=500, detail="No state change returned")
173
+
174
+ return {
175
+ "success": True,
176
+ "state": {
177
+ "previous": stopping_instances[0]["PreviousState"]["Name"],
178
+ "current": stopping_instances[0]["CurrentState"]["Name"],
179
+ },
180
+ }
181
+ except client.exceptions.ClientError as e:
182
+ error_code = e.response["Error"]["Code"]
183
+ if error_code == "InvalidInstanceID.NotFound":
184
+ raise HTTPException(status_code=404, detail=f"Instance {instance_id} not found")
185
+ raise HTTPException(status_code=400, detail=str(e))
186
+ except Exception as e:
187
+ raise HTTPException(status_code=500, detail=str(e))
188
+
189
+
190
+ @router.post("/instances/{instance_id}/reboot")
191
+ def reboot_instance(instance_id: str) -> dict[str, Any]:
192
+ """Reboot an EC2 instance."""
193
+ try:
194
+ client = get_client("ec2")
195
+ client.reboot_instances(InstanceIds=[instance_id])
196
+
197
+ return {"success": True, "message": f"Instance {instance_id} reboot initiated"}
198
+ except client.exceptions.ClientError as e:
199
+ error_code = e.response["Error"]["Code"]
200
+ if error_code == "InvalidInstanceID.NotFound":
201
+ raise HTTPException(status_code=404, detail=f"Instance {instance_id} not found")
202
+ raise HTTPException(status_code=400, detail=str(e))
203
+ except Exception as e:
204
+ raise HTTPException(status_code=500, detail=str(e))
205
+
206
+
207
+ @router.post("/instances/{instance_id}/terminate")
208
+ def terminate_instance(instance_id: str) -> dict[str, Any]:
209
+ """Terminate an EC2 instance."""
210
+ try:
211
+ client = get_client("ec2")
212
+ response = client.terminate_instances(InstanceIds=[instance_id])
213
+
214
+ terminating_instances = response.get("TerminatingInstances", [])
215
+ if not terminating_instances:
216
+ raise HTTPException(status_code=500, detail="No state change returned")
217
+
218
+ return {
219
+ "success": True,
220
+ "state": {
221
+ "previous": terminating_instances[0]["PreviousState"]["Name"],
222
+ "current": terminating_instances[0]["CurrentState"]["Name"],
223
+ },
224
+ }
225
+ except client.exceptions.ClientError as e:
226
+ error_code = e.response["Error"]["Code"]
227
+ if error_code == "InvalidInstanceID.NotFound":
228
+ raise HTTPException(status_code=404, detail=f"Instance {instance_id} not found")
229
+ raise HTTPException(status_code=400, detail=str(e))
230
+ except Exception as e:
231
+ raise HTTPException(status_code=500, detail=str(e))
232
+
233
+
234
+ @router.get("/security-groups")
235
+ def list_security_groups() -> dict[str, Any]:
236
+ """List all security groups with rules."""
237
+ try:
238
+ client = get_client("ec2")
239
+ response = client.describe_security_groups()
240
+
241
+ security_groups = []
242
+ for sg in response.get("SecurityGroups", []):
243
+ security_groups.append(
244
+ {
245
+ "groupId": sg["GroupId"],
246
+ "groupName": sg["GroupName"],
247
+ "description": sg.get("Description", ""),
248
+ "vpcId": sg.get("VpcId"),
249
+ "ipPermissions": sg.get("IpPermissions", []),
250
+ "ipPermissionsEgress": sg.get("IpPermissionsEgress", []),
251
+ "tags": sg.get("Tags", []),
252
+ }
253
+ )
254
+
255
+ return {"securityGroups": security_groups}
256
+ except Exception as e:
257
+ raise HTTPException(status_code=500, detail=str(e))
258
+
259
+
260
+ @router.get("/vpcs")
261
+ def list_vpcs() -> dict[str, Any]:
262
+ """List all VPCs with their subnets."""
263
+ try:
264
+ client = get_client("ec2")
265
+ vpcs_response = client.describe_vpcs()
266
+
267
+ vpcs = []
268
+ for vpc in vpcs_response.get("Vpcs", []):
269
+ vpc_id = vpc["VpcId"]
270
+
271
+ # Get subnets for this VPC
272
+ subnets_response = client.describe_subnets(
273
+ Filters=[{"Name": "vpc-id", "Values": [vpc_id]}]
274
+ )
275
+
276
+ subnets = []
277
+ for subnet in subnets_response.get("Subnets", []):
278
+ subnets.append(
279
+ {
280
+ "subnetId": subnet["SubnetId"],
281
+ "cidrBlock": subnet["CidrBlock"],
282
+ "availabilityZone": subnet["AvailabilityZone"],
283
+ "availableIpAddressCount": subnet.get("AvailableIpAddressCount", 0),
284
+ "state": subnet.get("State"),
285
+ "tags": subnet.get("Tags", []),
286
+ }
287
+ )
288
+
289
+ vpcs.append(
290
+ {
291
+ "vpcId": vpc_id,
292
+ "cidrBlock": vpc["CidrBlock"],
293
+ "state": vpc.get("State"),
294
+ "isDefault": vpc.get("IsDefault", False),
295
+ "tags": vpc.get("Tags", []),
296
+ "subnets": subnets,
297
+ }
298
+ )
299
+
300
+ return {"vpcs": vpcs}
301
+ except Exception as e:
302
+ raise HTTPException(status_code=500, detail=str(e))
303
+
304
+
305
+ @router.get("/key-pairs")
306
+ def list_key_pairs() -> dict[str, Any]:
307
+ """List all key pairs."""
308
+ try:
309
+ client = get_client("ec2")
310
+ response = client.describe_key_pairs()
311
+
312
+ key_pairs = []
313
+ for kp in response.get("KeyPairs", []):
314
+ key_pairs.append(
315
+ {
316
+ "keyPairId": kp.get("KeyPairId"),
317
+ "keyName": kp["KeyName"],
318
+ "keyFingerprint": kp.get("KeyFingerprint"),
319
+ "keyType": kp.get("KeyType", "rsa"),
320
+ "tags": kp.get("Tags", []),
321
+ }
322
+ )
323
+
324
+ return {"keyPairs": key_pairs}
325
+ except Exception as e:
326
+ raise HTTPException(status_code=500, detail=str(e))